TAG PLATFORM — ALGORITHM DOCUMENTATION
=======================================
Covers: TagVibe Feed, Public Post Feed, TagSpace Suggestions
Last updated: June 2026


========================================================================
SECTION 1 — TAGVIBE FEED
========================================================================

The TagVibe feed is built by combining a scored main feed (80%) with a
diversity pool (20%) of vibes from outside the viewer's social graph.

------------------------------------------------------------------------
1.1  MAIN FEED SCORE
------------------------------------------------------------------------

Every vibe is assigned a score computed entirely in SQL before sorting:

  feed_score =
    (
      (remasters * 3.0)
      + (bookmarks * 2.0)
      + (weighted_reactions * 1.5)
      + (comments * 1.2)
      + ((avg_watched_pct / 100.0) * views * 0.003)
    )
    / GREATEST(1, hours_since_creation)
    * EXP(-0.04 * hours_since_creation)   ← recency decay
    * social_boost
    * location_boost
    * interest_boost

Reaction weighting (weighted_reactions):
  Each reaction type counts differently before the 1.5x multiplier:
    fire   → 1.5
    love   → 1.5
    like   → 1.0
    laugh  → 0.8
    sad    → 0.5
    other  → 0.3

  avg_watched_pct defaults to 50 when no watch data exists.

Recency decay:
  The score is divided by hours_since_creation (floored at 1) and then
  multiplied by EXP(-0.04 * hours), which halves a vibe's raw score
  roughly every 17 hours. A vibe with no engagement falls off quickly;
  a high-engagement vibe decays more slowly.

Social boost (pick the highest that applies):
  Viewer follows the creator              → x2.0
  Creator is in viewer's contacts         → x1.8
  No connection                           → x1.0

Location boost (applied only when viewer shares GPS):
  Distance to vibe creator's location:
    Within  2 km  → x3.0
    Within 10 km  → x2.0
    Within 50 km  → x1.3
    Beyond 50 km  → x1.0
  If viewer location is unavailable       → x1.0 (no effect)

Interest boost:
  The platform stores per-user interest weights. For each content category
  tag on a vibe that matches a viewer interest:
    boost += 1.0 + (interest_weight * 0.5)
  Minimum value: 1.0 (no matching interests → no boost)

Eligibility filters for main feed:
  - Vibe must not be soft-deleted
  - Vibe author must not be soft-deleted
  - Vibe must have at least one media attachment
  - Cursor pagination: only vibes with ID < current cursor are returned
    (0 = first page, returns the newest scored vibes)

Sort order: feed_score DESC, then vibe ID DESC to break ties.
Page size:  15 vibes per request.

------------------------------------------------------------------------
1.2  DIVERSITY POOL
------------------------------------------------------------------------

Purpose: prevent the feed from being entirely echo-chamber content.
After the main feed is fetched, a separate query pulls up to 3 vibes
from authors the viewer has NO social connection with.

Diversity pool filters:
  - Same media and soft-delete requirements as main feed
  - Author must NOT be the viewer
  - Author must NOT be followed by the viewer
  - Author must NOT be in the viewer's contacts
  - Vibe ID must NOT already appear in the main feed results

Diversity pool ranking: no scoring — ordered by views_count DESC, then
created_at DESC (most-viewed recent vibes from strangers).

------------------------------------------------------------------------
1.3  FEED ASSEMBLY (how main + diversity are merged)
------------------------------------------------------------------------

The controller interleaves diversity vibes into the main feed at every
5th position to achieve roughly 20% diverse content:

  Positions in final feed (0-indexed): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ...
  Diversity injected at positions:         4,          9,          14

  Logic:
    For each position in the output (up to 15 slots):
      If (position + 1) is divisible by 5 AND diversity items remain:
        → insert a diversity vibe
      Else if main feed items remain:
        → insert a main feed vibe
      Else if diversity items remain (main feed exhausted):
        → insert remaining diversity vibes

  Example (M = main, D = diversity):
    M M M M D | M M M M D | M M M M D

------------------------------------------------------------------------
1.4  FALLBACK LAYER (graceful degradation)
------------------------------------------------------------------------

If the combined main + diversity feed still has fewer than 15 items
(e.g. early platform with few vibes), a trending fallback fills the
remaining slots.

Trending fallback query:
  - Excludes vibe IDs already present in the assembled feed
  - No recency decay, no social/location/interest boosts
  - Sorted by: (views_count + remasters_count * 5) DESC, then created_at DESC
  - This surfaces the most-watched and most-remasted vibes on the
    platform regardless of when they were posted

The fallback runs as a separate query only when needed. On a healthy
platform with enough vibes, it is never triggered.

Cursor note: the next_cursor in the response is based on the primary
scored feed only. If the fallback was triggered (primary was sparse),
next_cursor is null — the client treats this as the end of the feed
for this session and can refresh on next open.

------------------------------------------------------------------------
1.5  AUTHOR FEED
------------------------------------------------------------------------

When viewing a specific user's profile vibes, no scoring is applied.
Results are returned newest-first (ORDER BY vibe ID DESC) with the same
cursor-based pagination.


========================================================================
SECTION 2 — PUBLIC POST FEED
========================================================================

Public posts use the same scoring architecture as TagVibes but without
diversity injection. There is also an optional "seen suppression" filter.

------------------------------------------------------------------------
2.1  MAIN FEED SCORE
------------------------------------------------------------------------

  feed_score =
    (
      weighted_reactions
      + (comments * 1.2)
      + (views * 0.005)
    )
    / GREATEST(1, hours_since_creation)
    * EXP(-0.04 * hours_since_creation)   ← same recency decay
    * social_boost
    * location_boost
    * interest_boost

Reaction weighting (same as TagVibes):
    fire/love → 1.5,  like → 1.0,  laugh → 0.8,  sad → 0.5,  other → 0.3

Note: Public posts do not track remasters or bookmarks, so those
components are absent. The views multiplier is slightly higher (0.005
vs 0.003) to compensate.

Social boost:   same as TagVibes (x2.0 follow, x1.8 contact, x1.0 none)
Location boost: same as TagVibes (x3.0 / x2.0 / x1.3 / x1.0)
Interest boost: same formula as TagVibes (1.0 + weight * 0.5 per match)

Recency decay: identical to TagVibes — EXP(-0.04 * hours)

Seen post suppression (enabled by default):
  Posts already viewed by the viewer (tracked in public_post_user_views)
  are excluded from the feed. This can be disabled by the caller.

Eligibility filters:
  - Post must not be soft-deleted
  - Post author must not be soft-deleted
  - Cursor pagination (same ID-based mechanism as TagVibes)

Sort order: feed_score DESC, then post ID DESC.
Page size:  15 posts per request.

------------------------------------------------------------------------
2.2  FALLBACK LAYERS (graceful degradation)
------------------------------------------------------------------------

The post feed runs a three-layer waterfall to ensure the page is never
returned empty, even when the user has seen all available content.

Layer 1 — Primary scored feed (always runs first):
  The standard feed with seen-suppression, recency decay, social boost,
  location boost, and interest boost. If this returns 15 posts, layers
  2 and 3 are skipped entirely.

Layer 2 — Trending unseen posts (runs if Layer 1 < 15):
  Fetches the highest-engagement posts created in the last 7 days that
  the viewer has NOT yet seen (seen-suppression still active).
  Sort: pure engagement score (weighted reactions + comments*1.2 + views*0.005)
        with NO recency decay — surfaces viral content regardless of age
        within the 7-day window.
  This layer fills up to (15 - Layer 1 count) slots.

Layer 3 — Best seen posts / last resort (runs if Layer 1 + Layer 2 < 15):
  Fetches the highest-engagement posts from all time, including posts the
  viewer has already seen. No time restriction, no seen-suppression.
  Same pure engagement sort as Layer 2.
  This layer fills the remaining slots.

The user is never shown an empty feed as long as any posts exist on the
platform. The transition between layers is invisible to the client.

Cursor behaviour:
  next_cursor is set only when Layer 1 fills the full page (15 items).
  If any fallback layer was triggered, next_cursor is null — the client
  treats this as "end of feed" and refreshes on next open.

  The seen-suppression filter (public_post_user_views) acts as a natural
  dedup across pages: once a post is viewed, it won't reappear in Layer 1
  or Layer 2 on subsequent pages.

------------------------------------------------------------------------
2.3  AUTHOR FEED
------------------------------------------------------------------------

When viewing a specific user's public posts, no scoring is applied.
Results are returned newest-first (ORDER BY created_at DESC). No cursor
pagination — all posts are returned in one response.


========================================================================
SECTION 3 — TAGSPACE SUGGESTIONS
========================================================================

Triggered when the viewer opens the "Discover" tab. Returns up to 20
ranked public spaces the viewer has not joined and did not create.

------------------------------------------------------------------------
3.1  SUGGESTION SCORE
------------------------------------------------------------------------

  suggestion_score =
    (connection_score + activity_boost + interest_boost)
    * location_boost

Connection score (social-graph signals):
  +4.0  per contact (tag-book entry) currently active in the space
  +2.0  per followed user currently active in the space
  +3.0  if the space creator is one of the viewer's contacts
  +2.5  if the space creator is someone the viewer follows

  Example: 3 contacts active in a space → +12.0 connection score

Activity boost (recency window: last 48 hours):
  +1.5  per post published in the space within the last 48 hours
  +2.0  per new member who joined the space within the last 48 hours

Interest boost:
  For each content category tag on the space that matches a viewer interest:
    boost += interest_weight * 2.0
  Minimum: 0 (no match → no boost)

Location boost (multiplier applied to the full sum above):
  Distance to the space's registered location:
    Within  2 km  → x4.0
    Within 10 km  → x2.5
    Within 50 km  → x1.5
    Beyond 50 km  → x1.1
  If the space has no location or viewer location is unavailable → x1.0

------------------------------------------------------------------------
3.2  ELIGIBILITY FILTERS
------------------------------------------------------------------------

A space is excluded from suggestions if any condition is true:
  - Space is not active (is_active = 0)
  - Space is not public (visibility != 'public')
  - Viewer is the creator of the space
  - Viewer already has an active membership (active session) in the space
  - Space has a member cap and that cap has been reached

------------------------------------------------------------------------
3.3  FALLBACK LAYER (graceful degradation)
------------------------------------------------------------------------

If the primary suggestions query returns fewer than 20 spaces, a trending
fallback fills the remaining slots.

Trending fallback query:
  - Same eligibility filters as primary (public, active, not joined,
    not full, not creator)
  - Excludes space IDs already present in the primary results
  - No scoring formula — sorted purely by:
      (posts in last 7 days * 2) + total active members  DESC,
      then created_at DESC
  - This surfaces the most-active and fastest-growing spaces on the
    platform regardless of whether the viewer has any social connection
    to them

When does the fallback trigger?
  A new user with no social graph and no location will still get a full
  list of 20 spaces because the scoring formula has a floor at 0 and the
  primary query considers ALL public unjoinable spaces globally. The
  fallback is most relevant early in the platform's life when few spaces
  exist, or for a user who has already joined most available spaces.

------------------------------------------------------------------------
3.4  SORT ORDER AND PAGE SIZE
------------------------------------------------------------------------

Primary sort: suggestion_score DESC, then created_at DESC (newest wins ties)
Fallback sort: (recent_posts * 2 + active_members) DESC, then created_at DESC
Page size: 20 spaces per request (no cursor pagination)


========================================================================
SECTION 4 — SPACE POST FEED
========================================================================

Posts inside a specific space are not ranked or scored. They are returned
newest-first (ORDER BY created_at DESC). Soft-deleted posts are excluded.


========================================================================
SECTION 5 — DESIGN PRINCIPLES SUMMARY
========================================================================

1. All scoring is computed inside a single SQL query (no application-side
   re-ranking). This keeps the logic atomic and consistent.

2. Recency decay (EXP(-0.04 * hours)) is shared across TagVibes and
   Public Posts. It ensures fresh content competes with high-engagement
   older content without completely burying it.

3. The social graph is the strongest signal for personalised feeds.
   Following someone (x2.0) gives their content roughly double visibility;
   contacts (x1.8) are slightly weaker because contact lists tend to be
   broader. For spaces, the same relationships produce additive scores
   (+4.0 / +2.0 per active member).

4. Location is a multiplier, not an additive term. A space or vibe with
   zero social/interest relevance but very close proximity still only
   multiplies by a low base score. Proximity amplifies relevance; it does
   not create it.

5. Diversity injection (TagVibe feed only) prevents the feed from becoming
   purely social-graph content. 20% of slots (positions 5, 10, 15) are
   reserved for vibes from accounts outside the viewer's network, ranked
   purely by view count.

6. All feeds use soft-deletion: records are never physically removed,
   only excluded by a deleted_at IS NULL filter.

7. Every feed implements graceful degradation through fallback layers.
   When ideal personalised content is exhausted, the algorithm silently
   relaxes constraints (removes recency decay, drops seen-suppression,
   ignores social graph) to keep the feed populated with the best
   available content. The user never sees an empty feed.

   Feed waterfall order:
     Public Posts:  scored+unseen → trending unseen (7d) → best seen (all time)
     TagVibes:      scored+diversity → trending all-time fill
     TagSpaces:     scored suggestions → trending activity fill

   Fallback queries only run when the higher-priority layer returns fewer
   items than the page size. On a healthy platform they are never called.
