From 3641ec9e8eb85e1f351770249b0c7e5349fe5216 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 22:23:12 +0400 Subject: [PATCH 1/8] docs: add Vibe v2 session director specification --- vibe-v2-spec.md | 926 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 926 insertions(+) create mode 100644 vibe-v2-spec.md diff --git a/vibe-v2-spec.md b/vibe-v2-spec.md new file mode 100644 index 0000000..8f46a4b --- /dev/null +++ b/vibe-v2-spec.md @@ -0,0 +1,926 @@ +# Vibe v2 — Session Director Specification + +## Status and purpose + +This is the target specification for Vibe v2. It turns Vibe from a +recommendation queue into a session director: an autonomous system that +continuously composes the listener's next hour of music. + +The unit of optimisation is not the next track. It is a listening experience +that remains coherent, fresh, and rewarding over hours and continues to feel +new tomorrow. + +This document supplements docs/architecture/09-recommendation-and-identity-v2.md. +Where the two documents conflict, this document controls the Session Director +and Vibe playback contract. + +## Product contract + +Vibe must feel like a thoughtful radio DJ: + +- It plays familiar music, adjacent music, and worthwhile discoveries in a + deliberate balance. +- It responds to a skip, full listen, or explicit action quickly enough that + the listener can perceive the change. +- It does not collapse into a few favourite artists, genres, languages, + decades, labels, producers, or audio-feature bands. +- It has a direction: energy, novelty, and intensity can evolve, but should + not jump without a reason. +- It returns to ideas introduced earlier (callbacks) and makes room for + surprises. +- It remembers prior sessions sufficiently to avoid replaying yesterday's + shape, not merely yesterday's tracks. + + durable history + live feedback + context + | + v + listener state + | + v + candidate retrieval and expansion + | + v + sequence planner / constraint engine + | + v + revisable next 20–50 tracks + | + v + playback + | + +------------ feedback and replan ------------+ + +Vibe is not a saved playlist. A client may display a small, revisable preview +of the future, but the server owns the authoritative plan and may rewrite every +unplayed item at any time. + +## Goals and non-goals + +### Goals + +1. Maximise expected session reward, not click-through rate or immediate + predicted enjoyment alone. +2. Make recommendations conditional on current state, listening context, and + the already-played portion of the session. +3. Balance comfort, discovery, diversity, coherence, freshness, and long-term + learning while penalising fatigue, repetition, and predictability. +4. Support a self-hosted local library first. Probation tracks are candidates + only when their audio is available locally and acquisition policy permits it. +5. Remain useful with sparse metadata. Missing attributes reduce confidence; + they must never silently become a hard negative. +6. Be explainable: every selected item has provenance, a reason for its slot, + and the constraints that affected it. + +### Non-goals + +- Do not acquire copyrighted audio or bypass existing acquisition gates. +- Do not infer sensitive personal attributes. Context is opt-in and coarse + (for example home, work, walking), never precise location. +- Do not treat a permanently fixed exploration ratio, genre quota, or score + weight as the final design. Defaults are bootstraps, not truth. +- Do not expose internal goals in a way that makes the experience feel + manipulative. Explanations remain human-scale. + +--- + +## 1. System model + +### 1.1 Inputs + +| Input | Examples | Role | +|---|---|---| +| Permanent taste | favourite artists, genre affinity, negative feedback | Establishes the comfort zone | +| Multi-horizon memory | 30 minutes, 7 days, 3 months, lifetime | Separates current obsession from durable taste | +| Current session | played/queued tracks, skips, callbacks, budget spend | Determines what fits now | +| Context | hour, weekday, device, activity, coarse location, optional weather | Changes interpretation of taste | +| Music knowledge | graph claims, metadata, audio features, embeddings, quality | Retrieves and describes candidates | + +### 1.2 Optimisation objective + +For sequence q = [t1, …, tn], optimise discounted sequence reward rather than +independently sorting tracks: + + J(q | state) = sum over i of gamma^i × ( + enjoyment(ti) + + discovery_value(ti) + + transition_quality(ti-1, ti) + + freshness(ti) + + diversity_gain(q through i) + + goal_progress(ti) + - fatigue(ti) + - repetition(ti, q through i) + - predictability(q through i) + - disruption(ti-1, ti) + ) + +Hard safety and availability constraints apply before optimisation. Gamma +discounts distant slots so the director is decisive about the next few tracks +without pretending it knows the exact state 40 tracks later. + +Initial weights may be hand-tuned, but must be versioned policy configuration. +They become learnable only after sufficient reliable events exist. + +### 1.3 Planning horizon + +- Keep an internal horizon of 20–50 tracks, chosen from track duration and + session conditions. +- Publish only 3–8 tracks to the client as a mutable preview. +- Treat only the immediate next track as committed. +- Replan after every material event and before the preview falls below three + playable tracks. +- Do not send duplicate tracks in one Vibe session unless repeat-one was + explicitly requested. + +--- + +## 2. Durable session model + +### 2.1 Session identity + +Every Vibe request, playback event, plan version, and feedback event MUST be +keyed by session_id and user_id. play_history.batch_id is not a session ID and +must not be used as one. + +Sessions end explicitly, after configurable inactivity, or when a new Vibe +session replaces the current one. A session can resume in a short grace period +without losing state or goals. + +### 2.2 Session context + +Context is optional, versioned, and privacy-preserving. + + interface VibeContext { + timeZone?: string; + localHour?: number; // normally server-derived + weekday?: number; // normally server-derived + dayKind?: 'weekday' | 'weekend' | 'holiday'; + device?: 'desktop' | 'phone' | 'speaker' | 'car' | 'headphones'; + activity?: 'focus' | 'relax' | 'walking' | 'workout' | 'social' | 'unknown'; + locationCategory?: 'home' | 'work' | 'gym' | 'travel' | 'unknown'; + weather?: 'clear' | 'rain' | 'snow' | 'hot' | 'cold' | 'unknown'; + source?: 'current_track' | 'artist' | 'genre' | 'surprise' | 'resume'; + } + +The frontend must provide an unobtrusive activity/context selector, beginning +with activity and device. Browser context is a hint and is never required. + +### 2.3 Event ledger + +Store immutable events before updating derived state. This makes feedback +auditable, supports offline evaluation, and prevents listener state being the +only record of why a plan changed. + + CREATE TABLE vibe_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status TEXT NOT NULL CHECK (status IN + ('active','paused','ended','expired','replaced')), + seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + context JSONB NOT NULL DEFAULT '{}'::jsonb, + policy_version TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ + ); + + CREATE TABLE vibe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_event_id UUID, + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + type TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + position_ms INTEGER, + duration_ms INTEGER, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (session_id, client_event_id) + ); + + CREATE TABLE vibe_plan_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + reason TEXT NOT NULL, + state_snapshot JSONB NOT NULL, + objective_snapshot JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (session_id, version) + ); + + CREATE TABLE vibe_plan_items ( + plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + slot_role TEXT, + candidate_source TEXT NOT NULL, + score REAL NOT NULL, + score_breakdown JSONB NOT NULL, + explanation JSONB NOT NULL, + committed BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (plan_version_id, ordinal), + UNIQUE (plan_version_id, track_id) + ); + +Create indexes on vibe_sessions(user_id, last_event_at DESC), +vibe_events(session_id, occurred_at), and vibe_events(user_id, occurred_at DESC). + +Initial event types: + + session_started, session_resumed, session_ended, context_changed, + plan_published, track_served, playback_started, progress, + completed, skipped, disliked, kept, favourite_added, queue_removed, + manual_search, album_opened, artist_opened, playlist_added, + track_replayed, volume_changed, playback_error + +Event writes must be idempotent. The client supplies client_event_id for any +action that might be retried after a network failure. + +### 2.4 Derived listener state + +session_state is a cache derived from events and recent history, not the sole +source of truth. It must be rebuildable. + + interface ListenerSessionState { + sessionId: string; + updatedAt: string; + ageMin: number; + + energy: number; + valence: number | null; + focus: number | null; + attention: number | null; + cognitiveLoad: number | null; + emotionalIntensity: number | null; + noveltyHunger: number; + noveltyTolerance: number; + danceabilityTarget: number | null; + acousticnessTarget: number | null; + instrumentalnessTarget: number | null; + vocalPreference: 'vocal' | 'instrumental' | 'mixed'; + tempoTarget: number | null; + moodTags: Array<{ tag: string; weight: number }>; + + explorationCoefficient: number; + discoveryRadius: number; + entropyTarget: number; + currentArc: ArcInstance; + activeGoals: SessionGoal[]; + callbackLedger: CallbackToken[]; + fatigue: FatigueSnapshot; + budgetSpend: BudgetSpendSnapshot; + recent: RecentSessionSummary; + } + +Every inferred value carries confidence and source. Low confidence causes +broader, safer choices; it never means the value is zero. + +### 2.5 State update rules + +- Completion shifts continuous state modestly toward reliable track features, + weighted by listen ratio and feature confidence. +- A quick skip is negative evidence for the track and recommendation route; it + is not automatically a dislike of every artist/genre/feature on the track. +- A saved discovery or intentional replay is strong evidence for exploration at + that distance and source. +- Repeated unfamiliar skips shrink exploration temporarily; repeated unfamiliar + completions expand it gradually. +- Context changes may reset desired trajectory while retaining fatigue and + long-term memory. +- Updates MUST be ordered per session. Concurrent feedback must be serialized + or use a session-version compare-and-swap. + +--- + +## 3. Memory horizons and listener model + +The listener has independent memories, each with its own decay and purpose. + +| Horizon | Window | Captures | Primary effect | +|---|---:|---|---| +| Immediate | 30 minutes | current direction, skips, active fatigue | transitions and next slots | +| Session | session lifetime | arc, budgets, callbacks, served tracks | sequence planning | +| Daily | 24 hours | today’s exposure and shape | fatigue and fresh starts | +| Weekly | 7 days | recent obsessions and routines | variety across days | +| Medium | 3 months | stable recent taste | affinity and discovery neighbourhood | +| Lifetime | slow decay | durable favourites and familiar anchors | comfort candidates | + +Existing long-term, obsession, discovery, negative, forgotten, and contextual +belief profiles remain useful. Vibe adds a session layer; it does not flatten +all profiles into one taste score. + +### 3.1 Session similarity memory + +After each session, generate a compact fingerprint: + + interface SessionFingerprint { + artistDistribution: Record; + genreDistribution: Record; + languageDistribution: Record; + decadeDistribution: Record; + audioTrajectory: Array<{ energy: number; tempo?: number; valence?: number }>; + discoveryRate: number; + generatorDistribution: Record; + acceptedDiscoveries: string[]; + durationMin: number; + } + +At the next session start, softly penalise similarity to recent fingerprints, +especially one or two days ago. An explicit artist/album/genre seed may +intentionally override this penalty. + +--- + +## 4. Music representation and candidate retrieval + +### 4.1 Feature completeness + +Each playable track should expose, when available: + +- main/featured artists, album, label, producers, composers, scenes; +- genres and tags with confidence; +- release year/decade, language, vocal/instrumental classification; +- BPM, key, energy, valence, danceability, acousticness, instrumentalness, + liveness, loudness, duration; +- live version, cover, remix, soundtrack, and side-project relationships; +- quality, availability, and feature-completeness indicators. + +Missing audio analysis is unknown, never low energy or instrumental. + +### 4.2 Embeddings and discovery radius + +The long-term target is a shared semantic space for tracks, artists, albums, +genres, sessions, and listener/context representation. It may begin with +feature-derived vectors and later use learned embeddings. Recommended +dimensions are 768 or 1024. Musical, lyrical, collaborative, and contextual +vectors can be blended at retrieval time. + +| Distance | Interpretation | +|---:|---| +| 0.00 | already familiar/favourite | +| 0.15 | same artist or tightly associated artist | +| 0.30 | strongly similar artist/sound | +| 0.45 | different artist in a known style | +| 0.65 | adjacent genre or scene | +| 0.90 | surprising but explainable | + +discoveryRadius is a range, not a switch. Sequences normally move through +adjacent distances and return to an anchor. Large jumps need a surprise slot or +listener reinforcement. + +### 4.3 Candidate pools + +Generate independently, retain all provenance, deduplicate by track and +canonical identity, then rank. + +| Pool | Default share | Purpose | +|---|---:|---| +| Familiar/favourites | 30% | comfort and callbacks | +| Similar tracks/artists | 20% | continuity | +| User-niche recent/trending | 15% | timely relevant discovery | +| Long-tail discovery | 10% | avoid popularity collapse | +| Contextual candidates | 10% | fit activity/time | +| Artist/graph traversal | 10% | explainable adjacency | +| Controlled serendipity | 5% | bounded surprise | + +These are retrieval targets, not mandatory final-plan shares. Allocation adapts +to exploration, candidate availability, fatigue, and the active arc. + +Required generator classes: + +1. Comfort: favourites and trusted artists with fatigue suppression. +2. Adjacent: graph/ANN neighbours of recent successes/current trajectory. +3. Discovery: unfamiliar artists at a safe distance with credible graph path. +4. Revival: forgotten favourites, old obsessions, accepted tracks after rest. +5. Deep dive: album/obsession exploration limited by album/artist budgets. +6. Contextual: candidates with comparable activity, hour, or device evidence. +7. Freshness: new releases/niche trends fitting the listener graph. +8. Serendipity: deliberately bounded unexplored route, never random catalogue. + +Candidate provenance retains every nominating path, not just the winning source. +It is required for explanations and source-level learning. + +--- + +## 5. Fatigue, repetition, and diversity + +### 5.1 Fatigue principle + +Everything can fatigue; everything recovers. Fatigue is decayed exposure, not +a permanent ban. + + fatigue(dimension, entity, now) = + min(1, sum of exposure_weight(event) × exp(-event_age / tau_dimension)) + +Exposure weight is higher for completion, lower for a short sample, and zero +for playback failure. Tau is configurable per dimension and may be adapted from +observed tolerance. + +Track fatigue must blend multiple windows: today, yesterday, week, and month. +It must not be a single recent-history query. + +### 5.2 Required fatigue dimensions + +| Dimension | Example | Planner action | +|---|---|---| +| Track | heard today/replayed yesterday | strong suppression unless requested | +| Artist/canonical identity | 12 tracks by one artist | widen artist pool | +| Album | half an album played | spread across hours/days | +| Genre/scene | 18 metal tracks | move to adjacent style | +| Language | 35 Japanese tracks | mix another language/instrumental | +| Vocal/instrumentation | all vocal, same vocal type | alternate texture | +| Tempo/energy/valence | narrow BPM/mood band | controlled transition | +| Producer/label | repeated creative lineage | force new route | +| Decade | all recent releases | reintroduce another era | +| Recommendation route | same graph edge repeatedly | diversify path | + +Unknown metadata may not claim to satisfy a specific quota. + +### 5.3 Repetition rules + +Hard rules, unless explicitly overridden: + +- A served track never reappears in one Vibe session. +- A track in its configured recent window is ineligible. +- No more than two tracks by a canonical artist in 20 tracks. +- No more than three tracks from an album in 40 tracks. +- Skipped/disliked tracks are ineligible for the rest of the session. + +Soft, adaptive minimum distances apply to artist, album, genre, language, +producer, and energy band. If the pool is too small, relax the least important +soft constraint, record it, and never silently relax hard track exclusion. + +### 5.4 Diversity budgets + +Budgets are planner resources, not passive analytics. Initial defaults: + +| Dimension | Target | Horizon | +|---|---:|---:| +| Any artist | at most 20% | 30 min | +| Any genre | at most 40% | 30 min | +| Any language | at most 60% | 30 min | +| Instrumental | at least 10% if inventory permits | 30 min | +| New artists | about 15% | 60 min | +| Familiar favourites | about 25% | 60 min | + +Budgets may be upper bounds, lower bounds, or target ranges. The planner +projects spend across the proposed sequence, not only completed history. + +Explicit user intent may temporarily override soft targets. Starting an album +or repeatedly selecting an artist narrows the session intentionally; diversity +prevents accidental loops, not explicit choice. + +### 5.5 Anti-loop detector + +Run after every state update and every plan proposal. Detect concentration across +artist/canonical identity, album, genre, scene, label, producer, language, +decade, BPM, energy, valence, vocal type, candidate source, and graph path. + +Use HHI and Shannon entropy accurately; HHI must not be called entropy. +Correct the detected dimension directly: + +| Loop | Required correction | +|---|---| +| Artist/album | retrieve other artists; reserve only later callback | +| Genre/scene | retrieve adjacent genres at compatible energy | +| Language/vocal | reserve next eligible alternate slot | +| Tempo/energy | alter next arc target gradually | +| Producer/label/route | exclude repeated relationship from retrieval | +| Low route diversity | require another generator/path | + +Do not merely boost an already available experimental candidate if it does not +fix the detected loop. + +--- + +## 6. Arcs, callbacks, surprise, and long-term rhythm + +### 6.1 Arc templates + +An arc is desired trajectory plus slot roles and transition tolerance, not just +a generator list. + +| Arc | Example trajectory | +|---|---| +| Comfort | known → known → adjacent → favourite | +| Discovery | favourite → similar → new → familiar callback | +| Energetic | medium → high → peak → cooldown | +| Late-night | soft → ambient → acoustic → slow electronic | +| Focus | instrumental/low-vocal → steady complexity → gentle reset | +| Album exploration | familiar anchor → album chapter → relief → callback | + +Slots define target ranges/deltas for energy, tempo, valence, acousticness, +instrumentality, novelty distance, and familiarity. Missing features reduce +confidence rather than reject a candidate. + +Arc selection depends on state, context, age, explicit intent, and weekly +schedule. It changes only when feedback/context makes the current arc +implausible. + +### 6.2 Transition model + +For every pair, compute transition quality from: + +- tempo delta and beat compatibility where reliable; +- harmonic/key compatibility where available; +- energy, valence, acousticness, danceability, instrumentality deltas; +- genre/scene continuity or an explainable bridge; +- artist/album separation; +- language and vocal contrast when fatigue calls for it; +- novelty-distance progression; and +- current arc fit. + +A good transition is not always similarity. A cooldown after a peak or a +surprise after an anchor is good if it fits the arc. + +### 6.3 Callbacks + +A callback token is created when an artist, genre, energy peak, theme, or +favourite is worth revisiting. It has minimum/maximum separation and cannot +violate fatigue/repetition rules. + + favourite → new artist → adjacent artist → callback to favourite + heavy → soft bridge → different heavy track + forgotten favourite → side project → return to old era + +Callbacks are optional and must never become repetition. + +### 6.4 Surprise budget + +Every sustained session needs bounded surprise. Attempt at least one eligible, +explainable surprise per hour when inventory permits. Types include forgotten +favourite, live/acoustic/cover/remix, producer/side project, old obsession, +soundtrack connection, and novel graph route. + +A surprise is paired with a recovery anchor. A quick skip lowers propensity for +that surprise type, not the listener’s whole taste profile. + +### 6.5 Invisible goals + +Goals are durable, bounded, and never override hard constraints or explicit +direction. Examples: + +- complete an album over several days without a block; +- introduce a promising artist gradually; +- revisit a favourite monthly after recovery; +- balance languages/decades over a week; +- rotate producer/scene routes; +- evaluate a probation discovery; +- preserve a surprise opportunity per hour. + + CREATE TABLE vibe_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + type TEXT NOT NULL, + entity_type TEXT, + entity_id UUID, + state JSONB NOT NULL DEFAULT '{}'::jsonb, + priority REAL NOT NULL DEFAULT 0.5, + status TEXT NOT NULL CHECK (status IN ('active','paused','complete','expired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + due_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ + ); + +### 6.6 Weekly rhythm + +Vibe may use an opt-in weak weekly prior: Monday discoveries, Tuesday album +exploration, Wednesday comfort, Thursday forgotten favourites, Friday high +energy, weekend broad/context-led mix. It must never override explicit intent +or current context. + +--- + +## 7. Candidate ranking and sequence planning + +### 7.1 Candidate proposal score + + candidate_score = + w_affinity × personal_affinity + + w_session × session_state_fit + + w_transition × transition_fit_from_previous + + w_discovery × discovery_value + + w_freshness × freshness + + w_quality × track_quality + + w_goal × goal_progress + - w_fatigue × fatigue_penalty + - w_repeat × repetition_penalty + - w_saturation × dimension_saturation + - w_risk × unsupported_metadata_or_route_risk + +This is a proposal score, not final selection. Persist normalised, +policy-versioned score breakdown with every selected plan item. + +### 7.2 Constraint engine + +Apply constraints during sequence construction: + +1. Exclude unavailable, hidden, retired, served, and blocked tracks. +2. Enforce hard repetition/safety constraints. +3. Satisfy arc slot requirements. +4. Project budgets across the horizon. +5. Penalise session similarity and path concentration. +6. Reserve callback, discovery, and surprise opportunities. +7. Select the highest-value feasible sequence. + +If no feasible sequence exists, return a degraded plan with structured +constraint_relaxations. Relax soft diversity targets, then arc precision, then +freshness. Never reintroduce an explicitly disliked or served track simply to +fill the plan. + +### 7.3 Planner algorithm + +Initial implementation is constrained beam search: + +- retrieve about 500–2,000 deduplicated candidates; +- keep beam width 20–50 with incremental objective and projected state; +- expand only compatible candidates per slot; +- plan 20–50 slots; commit first; publish preview only; +- replan from current state after feedback while preserving committed track and + still-valid callback/goal tokens. + +Later options include MCTS, transformer decoding, or model-predictive control. +The planner interface must stay algorithm-agnostic. + +### 7.4 Controlled unpredictability + +Measure predictability over recent and proposed sequence using artist, genre, +source/path, novelty distance, and feature distributions. + +- Too predictable: allocate adjacent/surprise candidate and protect a later + comfort callback. +- Too chaotic: shrink discovery radius and inject a familiar anchor. + +The target is intentional surprise, neither randomness nor maximum familiarity. + +--- + +## 8. Feedback and learning + +### 8.1 Feedback interpretation + +| Signal | Interpretation | Immediate action | +|---|---|---| +| Skip under 5 sec | strong mismatch | remove route/track, reduce local risk, replan | +| Skip under 20 sec | mismatch | penalise candidate/path, replan | +| Late skip | weak negative/transition issue | modest penalty | +| Completion | weak positive | update state/affinity modestly | +| Replay | strong positive | reinforce affinity and comfort | +| Keep/favourite | strong positive | reinforce entities and route | +| Playlist add/share | very strong positive | reinforce strongly | +| Mute/hide artist/genre | strong negative | exclude or suppress | +| Manual search/open album | intent evidence | bias session appropriately | +| Volume change | weak/noisy evidence | aggregate with other signals | +| Session abandonment | delayed negative reward | evaluate preceding sequence | + +Completion uses actual position and duration. Long intros must not be treated +exactly like short tracks. + +### 8.2 Dynamic exploration + +Maintain exploration coefficient E in range 0–1 and discovery radius. + + quick skip of unfamiliar track E -= 0.05 + completion of unfamiliar track E += 0.03 + save/favourite unknown artist E += 0.12 + repeated known completion E -= 0.01 only if comfort over budget + explicit more-discovery control bounded immediate increase + +Clamp and smooth updates so one event does not whiplash a session. Persist the +evidence. play_of_never_seen or equivalent MUST be emitted by production +playback; a read-only schema capability is not a feature. + +### 8.3 Learning rollout + +1. Ship deterministic rules with complete event and plan logging. +2. Replay historical sessions for offline evaluation. +3. Use contextual bandits for calibrated immediate/short-horizon weights and + generator routing under safety limits. +4. Consider offline RL/model-based sequence policy only after reliable + off-policy evaluation exists. + +Retain deterministic fallback and a kill switch for learned policy. + +--- + +## 9. API and frontend contract + +### 9.1 Start/resume + + POST /api/v2/vibe/sessions + + { + "seedTrackId": "uuid or optional", + "context": { "activity": "focus", "device": "headphones" }, + "intent": "optional mode", + "resumeSessionId": "uuid or optional" + } + +Response includes sessionId, planVersion, now, revisable preview, and concise +state summary. User identity comes from authenticated/trusted request context, +not a silent shared default UUID. + +### 9.2 Playback event + + POST /api/v2/vibe/sessions/:sessionId/events + + { + "eventId": "client UUID", + "type": "progress | completed | skipped | kept | disliked | ...", + "trackId": "uuid", + "positionMs": 12500, + "durationMs": 203000, + "payload": {} + } + +Response includes canonical planVersion, replacement preview, state summary, +and replan reason. + +The client MUST reconcile its unplayed Vibe buffer whenever replacement preview +is returned. It must not keep playing stale prefetched items only because they +were fetched before feedback. The currently loaded track is not interrupted +unless the listener explicitly skips it. + +### 9.3 Serve next + + POST /api/v2/vibe/sessions/:sessionId/next + { "expectedPlanVersion": 4 } + +The response is idempotent for request/plan version and marks the item served. +If a newer plan exists, return that preview instead of a stale track. + +### 9.4 UI requirements + +- Present Vibe as an evolving session, not static playlist. +- Show concise direction such as gentle discovery or late-night cooldown. +- Preview only revisable next tracks and label them adaptive. +- Offer Keep, Dislike, Skip, End, plus implicit progress tracking. +- Offer lightweight context and more-familiar/more-discovery controls. +- Explain a track on demand with provenance and human-readable reason. +- Do not let ordinary library actions accidentally write Vibe feedback. +- Preserve session identity through navigation and recover after transient + network failure. + +--- + +## 10. Operational requirements + +### 10.1 Consistency and concurrency + +- Serialize plan mutation per session. +- Use monotonic planVersion and client optimistic concurrency. +- Redis may cache active plans; Postgres event/plan records are authoritative. + Redis expiry must never erase only session history. +- Stale-session reaper ends inactive sessions and creates final fingerprint; it + never deletes listener history. +- Revalidate track state and availability immediately before serving. + +### 10.2 Performance + +- Candidate retrieval p95 under 250 ms for warm local catalogue. +- Replan p95 under 750 ms for a 20-track baseline horizon. +- Serve-next p95 under 150 ms when valid plan exists. +- If ANN, external metadata, or context is unavailable, fall back to local + graph/metadata/favourites and record degraded source state. + +### 10.3 Observability + +Log/measure per policy version and session: + +- candidate counts/rejection reasons per generator; +- metadata coverage; +- constraint violations and relaxations; +- replan latency/reason; +- client stale-preview replacement success; +- fatigue/budget/entropy trajectories; +- discovery source/distance acceptance; +- session outcome metrics. + +Never log raw precise location or unnecessary personal context. + +--- + +## 11. Success metrics + +Primary metrics: + +- average uninterrupted listening duration; +- completed-session duration and return probability; +- discovery acceptance by distance/source; +- new artists saved/favourited; +- playlist additions and intentional replays; +- perceived freshness; +- artist/genre/language/path diversity and repetition rate; +- sessions with a successful surprise and healthy comfort anchor. + +Secondary diagnostics: + +- quick/medium skips, hides, abandonments; +- plan replacement latency; +- no-eligible-candidate rate; +- metadata coverage/fallback frequency; +- hard-constraint satisfaction; +- similarity to recent sessions. + +CTR is diagnostic only. It must not become the objective that causes +favourite-artist loops. + +--- + +## 12. Delivery phases and acceptance criteria + +### Phase 0 — Correct session contract + +Deliver durable sessions/events/versioned plans, session IDs on all playback +events, and client replacement of unplayed preview after replan. + +Acceptance: + +- A quick skip changes unplayed preview within one successful event round trip. +- Completion/skip cannot be attributed to another session. +- Backend restart does not lose event history or plan audit. + +### Phase 1 — Enforced fatigue and diversity + +Wire every budget/fatigue dimension used by policy into construction. Add album, +language, vocal/instrumental, and route controls. + +Acceptance: + +- Fixtures prove artist, album, genre, language, and track constraints. +- Changing a budget changes the plan, not only debug output. +- Anti-loop response corrects the detected dimension. + +### Phase 2 — Musical arcs and sequence planner + +Add feature access, transition scoring, callbacks, surprise tokens, constrained +beam search. + +Acceptance: + +- Energetic arc rises then cools within tolerance when features exist. +- Discovery arc anchors a discovery between familiar items. +- Chosen plans beat greedy order on offline fixture objective. + +### Phase 3 — Context, long-term goals, session memory + +Add context capture, fingerprints, weekly priors, and scheduling. + +Acceptance: + +- Context affects ranking only with comparable evidence; otherwise fallback is safe. +- Consecutive unseeded sessions are less similar than baseline without reducing + completion rate. +- Album/artist goals spread exposure across sessions. + +### Phase 4 — Dynamic exploration and learning + +Emit complete feedback, calibrate radius, add offline evaluation, then guarded +online learning. + +Acceptance: + +- Unknown-track completion/skip changes exploration in expected bounded direction. +- Learned decisions have policy version, feature log, and fallback. +- No rollout proceeds without offline and guardrail metrics. + +### Phase 5 — Embeddings and world model + +Introduce ANN retrieval and, only when data quality warrants it, learn: + + listener_state(t) + selected_track -> listener_state(t + 1) + +The controller can then select tracks partly for the state they create, not +only immediate affinity. This phase is optional and never blocks the +deterministic director. + +--- + +## 13. Test matrix + +| Scenario | Required assertion | +|---|---| +| Fast skip of unknown | preview replaced; radius shrinks; track excluded | +| Accepted discovery | radius grows; source receives positive attribution | +| Artist loop | cap respected; compatible bridge used | +| Language fatigue | alternate language/instrumental appears if available | +| Album deep dive | tracks spread; no accidental completion block | +| Low entropy | explainable diversity plus familiar anchor | +| High entropy | comfort inserted without favourite collapse | +| Context change | new preview without losing fatigue history | +| Redis loss | durable event/plan restores coherent preview | +| Sparse metadata | safe plan and reduced-confidence record | +| Explicit artist intent | soft diversity may yield; served tracks never repeat | +| Long session | no served duplicate; callbacks/surprise/budgets bounded | + +Use deterministic catalogue fixtures with known artists, albums, languages, +audio features, and graph routes. Test resulting sequences, not merely whether +a candidate list was sorted. + +## Final product definition + +Vibe succeeds when a listener can spend six hours with it and feel it +understood both their taste and their moment: it mixed comfort with meaningful +discovery, maintained a coherent evolving arc, avoided fatigue and obvious +loops, made memorable returns, and still left tomorrow feeling fresh. + From 515cab2f890c50b44f4c380828a2504508cb4e99 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 22:32:42 +0400 Subject: [PATCH 2/8] feat(vibe): persist durable session plans and events --- backend/src/db/migrations.test.ts | 23 ++ backend/src/db/migrations.ts | 64 ++++++ backend/src/db/schema.sql | 62 ++++++ backend/src/db/types.ts | 64 ++++++ backend/src/services/db.service.test.ts | 160 ++++++++++++++ backend/src/services/db.service.ts | 266 ++++++++++++++++++++++++ 6 files changed, 639 insertions(+) diff --git a/backend/src/db/migrations.test.ts b/backend/src/db/migrations.test.ts index edf3bd0..148c336 100644 --- a/backend/src/db/migrations.test.ts +++ b/backend/src/db/migrations.test.ts @@ -20,3 +20,26 @@ describe('track release-date migration', () => { expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)'); }); }); + +describe('Vibe durable session migration', () => { + const migration = MIGRATIONS.find( + ({ id }) => id === '20260801_vibe_session_persistence', + ); + + it('creates the event ledger, retry key, and revisioned plan tables', () => { + expect(migration).toBeDefined(); + expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_sessions'); + expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_events'); + expect(migration!.sql).toContain('UNIQUE (session_id, client_event_id)'); + expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_versions'); + expect(migration!.sql).toContain('UNIQUE (session_id, version)'); + expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_items'); + expect(migration!.sql).toContain('UNIQUE (plan_version_id, track_id)'); + }); + + it('indexes session and event reads along their required time axes', () => { + expect(migration!.sql).toContain('idx_vibe_sessions_user_last_event'); + expect(migration!.sql).toContain('idx_vibe_events_session_occurred'); + expect(migration!.sql).toContain('idx_vibe_events_user_occurred'); + }); +}); diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index f0a4bc9..c68d5c8 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -644,4 +644,68 @@ export const MIGRATIONS: Migration[] = [ EXECUTE FUNCTION propagate_album_release_date_to_tracks(); `, }, + { + // Vibe v2 needs an immutable event ledger and revisioned plans. The + // legacy session_state table remains in place as a derived-state cache so + // existing v2 endpoints can migrate independently. + id: '20260801_vibe_session_persistence', + sql: ` + CREATE TABLE IF NOT EXISTS vibe_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')), + seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + context JSONB NOT NULL DEFAULT '{}'::jsonb, + policy_version TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event + ON vibe_sessions (user_id, last_event_at DESC); + + CREATE TABLE IF NOT EXISTS vibe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_event_id UUID, + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + type TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + position_ms INTEGER, + duration_ms INTEGER, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (session_id, client_event_id) + ); + CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred + ON vibe_events (session_id, occurred_at); + CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred + ON vibe_events (user_id, occurred_at DESC); + + CREATE TABLE IF NOT EXISTS vibe_plan_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK (version > 0), + reason TEXT NOT NULL, + state_snapshot JSONB NOT NULL, + objective_snapshot JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (session_id, version) + ); + + CREATE TABLE IF NOT EXISTS vibe_plan_items ( + plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + slot_role TEXT, + candidate_source TEXT NOT NULL, + score REAL NOT NULL, + score_breakdown JSONB NOT NULL, + explanation JSONB NOT NULL, + committed BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (plan_version_id, ordinal), + UNIQUE (plan_version_id, track_id) + ); + `, + }, ]; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 6ca5be5..3a48764 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -525,6 +525,68 @@ CREATE TABLE IF NOT EXISTS session_state ( CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC); +-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for +-- the existing director; these tables are the authoritative record for the +-- next-generation, versioned planner. +CREATE TABLE IF NOT EXISTS vibe_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')), + seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + context JSONB NOT NULL DEFAULT '{}'::jsonb, + policy_version TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event + ON vibe_sessions (user_id, last_event_at DESC); + +CREATE TABLE IF NOT EXISTS vibe_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_event_id UUID, + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, + type TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + position_ms INTEGER, + duration_ms INTEGER, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (session_id, client_event_id) +); + +CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred + ON vibe_events (session_id, occurred_at); +CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred + ON vibe_events (user_id, occurred_at DESC); + +CREATE TABLE IF NOT EXISTS vibe_plan_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK (version > 0), + reason TEXT NOT NULL, + state_snapshot JSONB NOT NULL, + objective_snapshot JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (session_id, version) +); + +CREATE TABLE IF NOT EXISTS vibe_plan_items ( + plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + slot_role TEXT, + candidate_source TEXT NOT NULL, + score REAL NOT NULL, + score_breakdown JSONB NOT NULL, + explanation JSONB NOT NULL, + committed BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (plan_version_id, ordinal), + UNIQUE (plan_version_id, track_id) +); + -- Diversity budgets for the session director's planner. CREATE TABLE IF NOT EXISTS diversity_budgets ( user_id UUID NOT NULL, diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index c45eda7..fc51708 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -194,3 +194,67 @@ export interface RepetitionRule { dimension: string; min_distance: number; } + +// --------------------------------------------------------------------------- +// Vibe v2 durable session ledger +// --------------------------------------------------------------------------- + +export const VIBE_SESSION_STATUSES = ['active', 'paused', 'ended', 'expired', 'replaced'] as const; +export type VibeSessionStatus = (typeof VIBE_SESSION_STATUSES)[number]; + +export interface VibeSession { + id: string; + user_id: string; + status: VibeSessionStatus; + seed_track_id: string | null; + context: Record; + policy_version: string; + started_at: Date; + last_event_at: Date; + ended_at: Date | null; +} + +export interface VibeEvent { + id: string; + client_event_id: string | null; + session_id: string; + user_id: string; + track_id: string | null; + type: string; + occurred_at: Date; + position_ms: number | null; + duration_ms: number | null; + payload: Record; +} + +export interface RecordedVibeEvent { + event: VibeEvent; + /** False when a retried client_event_id returned the original event. */ + inserted: boolean; +} + +export interface VibePlanVersion { + id: string; + session_id: string; + version: number; + reason: string; + state_snapshot: Record; + objective_snapshot: Record; + created_at: Date; +} + +export interface VibePlanItem { + plan_version_id: string; + ordinal: number; + track_id: string; + slot_role: string | null; + candidate_source: string; + score: number; + score_breakdown: Record; + explanation: unknown; + committed: boolean; +} + +export interface VibePlan extends VibePlanVersion { + items: VibePlanItem[]; +} diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 1eff997..6bd5595 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -7,7 +7,167 @@ function makeService(): { service: DbService; mockQuery: ReturnType; clientQuery: ReturnType } { + const poolQuery = vi.fn(); + const clientQuery = vi.fn(); + const service = new DbService({ + query: poolQuery, + connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }), + } as any); + return { service, poolQuery, clientQuery }; +} + describe('DbService v2 methods', () => { + describe('durable Vibe sessions', () => { + it('creates, reads, and ends sessions scoped to their user', async () => { + const { service, mockQuery } = makeService(); + const session = { + id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null, + context: { activity: 'focus' }, policy_version: 'v2.1', + }; + mockQuery + .mockResolvedValueOnce({ rows: [session] }) + .mockResolvedValueOnce({ rows: [session] }) + .mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] }); + + await expect(service.createVibeSession({ + userId: 'user-1', policyVersion: 'v2.1', context: { activity: 'focus' }, + })).resolves.toEqual(session); + await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session); + await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' }); + + expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO vibe_sessions'); + expect(mockQuery.mock.calls[0][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']); + expect(mockQuery.mock.calls[1][0]).toContain('id = $1 AND user_id = $2'); + expect(mockQuery.mock.calls[2][0]).toContain('COALESCE(ended_at, NOW())'); + expect(mockQuery.mock.calls[2][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); + }); + + it('records retry-safe events and reports whether the event was inserted', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', + user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(), + position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' }, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session + .mockResolvedValueOnce({ rows: [event] }) // existing retry + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const result = await service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', + trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000, + payload: { reason: 'next' }, + }); + + expect(result.inserted).toBe(false); + expect(result.event.id).toBe('event-1'); + const [sql, values] = clientQuery.mock.calls[1]; + expect(sql).toContain('FOR UPDATE'); + expect(values).toEqual([ + 'session-1', 'user-1', + ]); + expect(clientQuery.mock.calls).toHaveLength(4); + expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain( + expect.stringContaining('UPDATE vibe_sessions') + ); + }); + + it('rejects an event when no owned session is returned', async () => { + const { service, clientQuery } = makeTransactionalService(); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // session lookup + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + await expect(service.recordVibeEvent({ + sessionId: 'session-1', userId: 'other-user', type: 'completed', + })).rejects.toThrow('not found or is not owned'); + }); + + it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', + user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(), + position_ms: null, duration_ms: null, payload: {}, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] }) + .mockResolvedValueOnce({ rows: [event] }) + .mockResolvedValueOnce({ rows: [] }) // COMMIT + .mockResolvedValueOnce({ rows: [] }) // BEGIN: new event + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] }) + .mockResolvedValueOnce({ rows: [] }) // no matching retry + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect(service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed', + })).resolves.toEqual({ event, inserted: false }); + await expect(service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed', + })).rejects.toThrow('Cannot record a new event for ended Vibe session'); + + expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain( + expect.stringContaining('INSERT INTO vibe_events') + ); + }); + }); + + describe('durable Vibe plans', () => { + it('writes a header and all items in one transaction', async () => { + const { service, clientQuery } = makeTransactionalService(); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ + id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started', + state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(), + }] }) + .mockResolvedValueOnce({ rows: [] }) // item + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const plan = await service.persistVibePlan({ + sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started', + stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 }, + items: [{ + ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort', + score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true, + }], + }); + + expect(plan.items[0].plan_version_id).toBe('plan-1'); + expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions'); + expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items'); + expect(clientQuery.mock.calls[3][0]).toBe('COMMIT'); + }); + + it('reads the latest revision and reconstructs ordered plan items', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ + id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback', + state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(), + item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next', + candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 }, + explanation: [{ because: 'similar artist' }], committed: true, + }, { + id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback', + state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(), + item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null, + candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 }, + explanation: [], committed: false, + }] }); + + const plan = await service.getVibePlan('session-1', 'user-1'); + + expect(plan?.version).toBe(2); + expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']); + expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions'); + expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]); + }); + }); + describe('createAlbum', () => { it('persists the canonical release date instead of discarding it', async () => { const { service, mockQuery } = makeService(); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index e987ce0..85cf023 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -10,6 +10,18 @@ import { SearchService } from './search.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; +type VibePlanVersionRow = Omit & { + item_plan_version_id: string | null; + ordinal: number | null; + track_id: string | null; + slot_role: string | null; + candidate_source: string | null; + score: number | null; + score_breakdown: Record | null; + explanation: unknown | null; + committed: boolean | null; +}; + import { MIGRATIONS } from '../db/migrations.js'; import { allowedFields } from '../db/updatable-columns.js'; @@ -33,6 +45,12 @@ import type { SessionState, DiversityBudget, RepetitionRule, + VibeSession, + VibeSessionStatus, + VibeEvent, + RecordedVibeEvent, + VibePlan, + VibePlanItem, } from '../db/types.js'; import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js'; export * from '../db/types.js'; @@ -1479,6 +1497,254 @@ export class DbService { return (res.rows[0] as SessionState) || null; } + /** + * Create the authoritative Vibe v2 session record. This intentionally does + * not create a legacy session_state row: callers can migrate to the durable + * ledger without changing the existing v2 endpoint contract first. + */ + async createVibeSession(params: { + userId: string; + policyVersion: string; + seedTrackId?: string | null; + context?: Record; + }): Promise { + const res = await this.pgClient.query( + `INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) + VALUES ($1, 'active', $2, $3::jsonb, $4) + RETURNING *`, + [ + params.userId, + params.seedTrackId ?? null, + JSON.stringify(params.context ?? {}), + params.policyVersion, + ] + ); + return res.rows[0] as VibeSession; + } + + /** Fetch a Vibe session only when it belongs to the requesting user. */ + async getVibeSession(sessionId: string, userId: string): Promise { + const res = await this.pgClient.query( + 'SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2', + [sessionId, userId] + ); + return (res.rows[0] as VibeSession) ?? null; + } + + /** + * End (or expire/replace) a session without changing its original end time + * when a client retries the same request. + */ + async endVibeSession( + sessionId: string, + userId: string, + status: Extract = 'ended' + ): Promise { + const res = await this.pgClient.query( + `UPDATE vibe_sessions + SET status = CASE WHEN ended_at IS NULL THEN $3 ELSE status END, + ended_at = COALESCE(ended_at, NOW()), + last_event_at = CASE WHEN ended_at IS NULL THEN NOW() ELSE last_event_at END + WHERE id = $1 AND user_id = $2 + RETURNING *`, + [sessionId, userId, status] + ); + return (res.rows[0] as VibeSession) ?? null; + } + + /** + * Append an immutable Vibe event. A supplied clientEventId is idempotent per + * session: a retry returns the original event and does not advance the + * session timestamp a second time. A missing id deliberately means a new, + * server-originated event. + */ + async recordVibeEvent(params: { + sessionId: string; + userId: string; + type: string; + clientEventId?: string | null; + trackId?: string | null; + occurredAt?: Date; + positionMs?: number | null; + durationMs?: number | null; + payload?: Record; + }): Promise { + return this.withTransaction(async (client) => { + // A session-row lock serializes both event writes and terminal state + // transitions. In particular, it avoids the READ COMMITTED CTE snapshot + // race where ON CONFLICT observes a concurrent event but a later CTE + // cannot yet read it. The duplicate lookup happens after the lock, so an + // idempotent retry remains valid even after the session has ended. + const sessionRes = await client.query( + `SELECT id, status + FROM vibe_sessions + WHERE id = $1 AND user_id = $2 + FOR UPDATE`, + [params.sessionId, params.userId] + ); + const session = sessionRes.rows[0] as Pick | undefined; + if (!session) { + throw new Error('Vibe session was not found or is not owned by this user'); + } + + if (params.clientEventId) { + const existingRes = await client.query( + `SELECT * + FROM vibe_events + WHERE session_id = $1 AND client_event_id = $2::uuid`, + [params.sessionId, params.clientEventId] + ); + const existing = existingRes.rows[0] as VibeEvent | undefined; + if (existing) { + return { event: existing, inserted: false }; + } + } + + if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') { + throw new Error(`Cannot record a new event for ${session.status} Vibe session`); + } + + const occurredAt = params.occurredAt?.toISOString() ?? null; + const insertRes = await client.query( + `INSERT INTO vibe_events + (client_event_id, session_id, user_id, track_id, type, occurred_at, position_ms, duration_ms, payload) + VALUES ($1::uuid, $2, $3, $4::uuid, $5, COALESCE($6::timestamptz, NOW()), $7, $8, $9::jsonb) + RETURNING *`, + [ + params.clientEventId ?? null, + params.sessionId, + params.userId, + params.trackId ?? null, + params.type, + occurredAt, + params.positionMs ?? null, + params.durationMs ?? null, + JSON.stringify(params.payload ?? {}), + ] + ); + const event = insertRes.rows[0] as VibeEvent | undefined; + if (!event) { + throw new Error('Vibe event could not be recorded'); + } + + await client.query( + `UPDATE vibe_sessions + SET last_event_at = GREATEST(last_event_at, $2::timestamptz) + WHERE id = $1`, + [params.sessionId, event.occurred_at] + ); + return { event, inserted: true }; + }); + } + + /** + * Persist one complete revision of a session plan atomically. The caller + * supplies the monotonically increasing version; session-level scheduling + * will own version allocation when the director is migrated to this ledger. + */ + async persistVibePlan(params: { + sessionId: string; + userId: string; + version: number; + reason: string; + stateSnapshot: Record; + objectiveSnapshot: Record; + items: Array>; + }): Promise { + return this.withTransaction(async (client) => { + const header = await client.query( + `INSERT INTO vibe_plan_versions + (session_id, version, reason, state_snapshot, objective_snapshot) + SELECT s.id, $3, $4, $5::jsonb, $6::jsonb + FROM vibe_sessions s + WHERE s.id = $1 AND s.user_id = $2 + RETURNING *`, + [ + params.sessionId, + params.userId, + params.version, + params.reason, + JSON.stringify(params.stateSnapshot), + JSON.stringify(params.objectiveSnapshot), + ] + ); + const planVersion = header.rows[0] as VibePlan | undefined; + if (!planVersion) { + throw new Error('Vibe session was not found or is not owned by this user'); + } + + for (const item of params.items) { + await client.query( + `INSERT INTO vibe_plan_items + (plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`, + [ + planVersion.id, + item.ordinal, + item.track_id, + item.slot_role, + item.candidate_source, + item.score, + JSON.stringify(item.score_breakdown), + JSON.stringify(item.explanation), + item.committed, + ] + ); + } + + return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) }; + }); + } + + /** Read a specific plan revision, or the latest revision for a session. */ + async getVibePlan(sessionId: string, userId: string, version?: number): Promise { + const res = await this.pgClient.query( + `SELECT p.*, i.plan_version_id AS item_plan_version_id, i.ordinal, i.track_id, + i.slot_role, i.candidate_source, i.score, i.score_breakdown, + i.explanation, i.committed + FROM vibe_plan_versions p + JOIN vibe_sessions s ON s.id = p.session_id + LEFT JOIN vibe_plan_items i ON i.plan_version_id = p.id + WHERE p.session_id = $1 AND s.user_id = $2 + AND ( + ($3::integer IS NOT NULL AND p.version = $3) + OR ($3::integer IS NULL AND p.version = ( + SELECT MAX(version) FROM vibe_plan_versions WHERE session_id = $1 + )) + ) + ORDER BY i.ordinal ASC`, + [sessionId, userId, version ?? null] + ); + if (!res.rows[0]) return null; + + const first = res.rows[0] as VibePlanVersionRow; + const plan: VibePlan = { + id: first.id, + session_id: first.session_id, + version: first.version, + reason: first.reason, + state_snapshot: first.state_snapshot, + objective_snapshot: first.objective_snapshot, + created_at: first.created_at, + items: [], + }; + for (const row of res.rows as VibePlanVersionRow[]) { + if (!row.item_plan_version_id) continue; + plan.items.push({ + plan_version_id: row.item_plan_version_id, + ordinal: row.ordinal!, + track_id: row.track_id!, + slot_role: row.slot_role, + candidate_source: row.candidate_source!, + score: row.score!, + score_breakdown: row.score_breakdown!, + explanation: row.explanation, + committed: row.committed!, + }); + } + return plan; + } + /** * Upsert a diversity budget for a user. */ From 51ef7c84db7f1087439a02ef19107deff88718e1 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 23:09:09 +0400 Subject: [PATCH 3/8] feat(vibe): add durable versioned session API --- backend/src/app.ts | 12 + backend/src/db/migrations.ts | 12 + backend/src/db/schema.sql | 8 + .../src/routes/vibe-sessions.routes.test.ts | 136 ++++++ backend/src/routes/vibe-sessions.routes.ts | 175 +++++++ backend/src/services/db.service.test.ts | 236 +++++++++- backend/src/services/db.service.ts | 442 +++++++++++++++++- .../src/services/session-director.service.ts | 6 + .../vibe-session-coordinator.service.test.ts | 198 ++++++++ .../vibe-session-coordinator.service.ts | 293 ++++++++++++ 10 files changed, 1494 insertions(+), 24 deletions(-) create mode 100644 backend/src/routes/vibe-sessions.routes.test.ts create mode 100644 backend/src/routes/vibe-sessions.routes.ts create mode 100644 backend/src/services/vibe-session-coordinator.service.test.ts create mode 100644 backend/src/services/vibe-session-coordinator.service.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 081a0fa..42ecdb0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,8 +15,10 @@ import settingsRoutes from './routes/settings.routes.js'; import graphRoutes from './routes/graph.routes.js'; import { SessionDirector } from './services/session-director.service.js'; import v2Routes from './routes/v2.routes.js'; +import vibeSessionsRoutes from './routes/vibe-sessions.routes.js'; import discoveryRoutes from './routes/discovery.routes.js'; import imagesRoutes from './routes/images.routes.js'; +import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js'; export interface AppConfig { port: number; @@ -158,8 +160,18 @@ export async function buildApp(config: AppConfig) { fastify.register(graphRoutes, { prefix: '/api', dbService }); const sessionDirector = new SessionDirector(dbService); + const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector); + // Durable sessions intentionally do not trust x-user-id. A self-hosted + // deployment may configure one owner through MUZICK_VIBE_USER_ID today; + // an authenticated deployment can replace this resolver at registration. + const vibeOwnerId = process.env.MUZICK_VIBE_USER_ID?.trim() || null; fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); + fastify.register(vibeSessionsRoutes, { + prefix: '/api', + coordinator: vibeSessionCoordinator, + identityResolver: () => vibeOwnerId, + }); fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService }); // ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed — // nothing in the deployed app or its tests called it, and deployment never diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index c68d5c8..ca57ab3 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -708,4 +708,16 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + // A material Vibe event is projected into the legacy listener inputs in + // the same transaction as its ledger write. This marker makes that bridge + // auditable and exactly-once even when a client retries an event id. + id: '20260801_vibe_event_projections', + sql: ` + CREATE TABLE IF NOT EXISTS vibe_event_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `, + }, ]; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 3a48764..105936a 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -562,6 +562,14 @@ CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred ON vibe_events (user_id, occurred_at DESC); +-- Exactly-once projection marker for material Vibe feedback. The immutable +-- event remains authoritative; this row proves its effect was applied to the +-- listener inputs without double-counting an idempotent client retry. +CREATE TABLE IF NOT EXISTS vibe_event_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS vibe_plan_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, diff --git a/backend/src/routes/vibe-sessions.routes.test.ts b/backend/src/routes/vibe-sessions.routes.test.ts new file mode 100644 index 0000000..0d03bf2 --- /dev/null +++ b/backend/src/routes/vibe-sessions.routes.test.ts @@ -0,0 +1,136 @@ +import Fastify from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; +import vibeSessionsRoutes, { VibeIdentityResolver } from './vibe-sessions.routes.js'; +import { VibeSessionLifecycleError } from '../services/vibe-session-coordinator.service.js'; + +const SESSION_ID = '11111111-1111-4111-8111-111111111111'; +const TRACK_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function response() { + return { + sessionId: SESSION_ID, planVersion: 1, now: null, preview: [], state: {}, + replanned: false, replanReason: null, + session: { id: SESSION_ID, status: 'active' }, + }; +} + +async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => USER_ID) { + const coordinator = { + start: vi.fn().mockResolvedValue(response()), + getPlan: vi.fn().mockResolvedValue(response()), + appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }), + end: vi.fn().mockResolvedValue(response()), + serveNext: vi.fn().mockResolvedValue(response()), + } as any; + const app = Fastify(); + await app.register(vibeSessionsRoutes, { coordinator, identityResolver }); + await app.ready(); + return { app, coordinator }; +} + +describe('durable Vibe session routes', () => { + it('uses the trusted identity resolver and never accepts a spoofed x-user-id header', async () => { + const { app, coordinator } = await appWithCoordinator(); + const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} }); + + expect(result.statusCode).toBe(201); + expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object)); + await app.close(); + }); + + it('rejects requests when no trusted identity is configured instead of defaulting a shared user', async () => { + const { app, coordinator } = await appWithCoordinator(() => null); + const result = await app.inject({ + method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {}, + }); + expect(result.statusCode).toBe(401); + expect(coordinator.start).not.toHaveBeenCalled(); + await app.close(); + }); + + it('creates a session and validates event payloads before touching the coordinator', async () => { + const { app, coordinator } = await appWithCoordinator(); + const created = await app.inject({ + method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' }, + payload: { seedTrackId: TRACK_ID, context: { activity: 'focus' }, policyVersion: 'test-policy' }, + }); + const invalidEvent = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' }, + payload: { type: 'definitely-not-an-event' }, + }); + + expect(created.statusCode).toBe(201); + expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ policyVersion: 'test-policy' })); + expect(invalidEvent.statusCode).toBe(400); + expect(coordinator.appendEvent).not.toHaveBeenCalled(); + await app.close(); + }); + + it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => { + const { app, coordinator } = await appWithCoordinator(); + coordinator.start.mockRejectedValueOnce( + new VibeSessionLifecycleError('Cannot publish a plan for ended Vibe session'), + ); + + const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} }); + + expect(result.statusCode).toBe(409); + expect(result.json()).toEqual({ + error: 'Cannot publish a plan for ended Vibe session', + code: 'VIBE_SESSION_NOT_ACTIVE', + }); + await app.close(); + }); + + it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => { + const { app, coordinator } = await appWithCoordinator(); + const plan = await app.inject({ + method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' }, + }); + const event = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' }, + payload: { eventId: '33333333-3333-4333-8333-333333333333', type: 'completed', trackId: TRACK_ID, positionMs: 5_000 }, + }); + + expect(plan.statusCode).toBe(200); + expect(coordinator.getPlan).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2); + expect(event.statusCode).toBe(200); + expect(coordinator.appendEvent).toHaveBeenCalledWith(USER_ID, SESSION_ID, expect.objectContaining({ type: 'completed', positionMs: 5_000 })); + await app.close(); + }); + + it('validates occurredAt and exposes owned resume and next operations', async () => { + const { app, coordinator } = await appWithCoordinator(); + const resume = await app.inject({ + method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID }, + }); + const badTime = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, + payload: { type: 'completed', occurredAt: 'not-a-date' }, + }); + const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next` }); + expect(resume.statusCode).toBe(201); + expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID })); + expect(badTime.statusCode).toBe(400); + expect(next.statusCode).toBe(200); + expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID); + await app.close(); + }); + + it('passes a version-aware next request through and rejects an invalid expected version', async () => { + const { app, coordinator } = await appWithCoordinator(); + const valid = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 2 }, + }); + const invalid = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 0 }, + }); + + expect(valid.statusCode).toBe(200); + expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2); + expect(invalid.statusCode).toBe(400); + expect(coordinator.serveNext).toHaveBeenCalledTimes(1); + await app.close(); + }); +}); diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts new file mode 100644 index 0000000..44762e9 --- /dev/null +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -0,0 +1,175 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { + VIBE_EVENT_TYPES, + VibeSessionCoordinator, + VibeSessionLifecycleError, + VibeSessionNotFoundError, + VibePlanNotFoundError, +} from '../services/vibe-session-coordinator.service.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Identity must come from authenticated server configuration/middleware, never a client header. */ +export type VibeIdentityResolver = (request: FastifyRequest) => string | null; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_RE.test(value); +} + +function validOccurredAt(value: unknown): value is string { + // Require an actual offset-bearing timestamp, rather than Date.parse's + // permissive inputs such as "2026" or locale-dependent strings. + return typeof value === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) + && !Number.isNaN(Date.parse(value)); +} + +export default async function vibeSessionsRoutes( + fastify: FastifyInstance, + options: { coordinator: VibeSessionCoordinator; identityResolver: VibeIdentityResolver }, +) { + const { coordinator, identityResolver } = options; + const requireUser = (request: FastifyRequest, reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }): string | null => { + const userId = identityResolver(request); + if (userId && validUuid(userId)) return userId; + reply.code(401).send({ error: 'A trusted Vibe identity is required' }); + return null; + }; + + fastify.post('/v2/vibe/sessions', async (request, reply) => { + const userId = requireUser(request, reply); + if (!userId) return; + const body = isObject(request.body) ? request.body : {}; + if (body.resumeSessionId !== undefined && !validUuid(body.resumeSessionId)) { + return reply.code(400).send({ error: 'resumeSessionId must be a UUID' }); + } + if (body.resumeSessionId !== undefined && body.seedTrackId !== undefined) { + return reply.code(400).send({ error: 'resumeSessionId cannot be combined with seedTrackId' }); + } + if (body.seedTrackId !== undefined && !validUuid(body.seedTrackId)) { + return reply.code(400).send({ error: 'seedTrackId must be a UUID' }); + } + if (body.context !== undefined && !isObject(body.context)) { + return reply.code(400).send({ error: 'context must be an object' }); + } + if (body.intent !== undefined && typeof body.intent !== 'string') { + return reply.code(400).send({ error: 'intent must be a string' }); + } + if (body.policyVersion !== undefined && (typeof body.policyVersion !== 'string' || !body.policyVersion.trim())) { + return reply.code(400).send({ error: 'policyVersion must be a non-empty string' }); + } + try { + return reply.code(201).send(await coordinator.start(userId, { + seedTrackId: body.seedTrackId as string | undefined, + context: body.context as Record | undefined, + intent: body.intent as string | undefined, + policyVersion: body.policyVersion as string | undefined, + resumeSessionId: body.resumeSessionId as string | undefined, + })); + } catch (error) { + return sendCoordinatorError(reply, error); + } + }); + + fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => { + const userId = requireUser(request, reply); + if (!userId) return; + const { sessionId } = request.params as { sessionId: string }; + const { version } = request.query as { version?: string }; + if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); + const parsedVersion = version === undefined ? undefined : Number(version); + if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) { + return reply.code(400).send({ error: 'version must be a positive integer' }); + } + try { + return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion)); + } catch (error) { + return sendCoordinatorError(reply, error); + } + }); + + fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => { + const userId = requireUser(request, reply); + if (!userId) return; + const { sessionId } = request.params as { sessionId: string }; + const body = isObject(request.body) ? request.body : null; + if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); + if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) { + return reply.code(400).send({ error: 'type must be a supported Vibe event type' }); + } + if (body.eventId !== undefined && !validUuid(body.eventId)) { + return reply.code(400).send({ error: 'eventId must be a UUID' }); + } + if (body.trackId !== undefined && !validUuid(body.trackId)) { + return reply.code(400).send({ error: 'trackId must be a UUID' }); + } + if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) { + return reply.code(400).send({ error: 'occurredAt must be an ISO-8601 timestamp' }); + } + if (body.positionMs !== undefined && (!Number.isInteger(body.positionMs) || (body.positionMs as number) < 0)) { + return reply.code(400).send({ error: 'positionMs must be a non-negative integer' }); + } + if (body.durationMs !== undefined && (!Number.isInteger(body.durationMs) || (body.durationMs as number) < 0)) { + return reply.code(400).send({ error: 'durationMs must be a non-negative integer' }); + } + if (body.payload !== undefined && !isObject(body.payload)) { + return reply.code(400).send({ error: 'payload must be an object' }); + } + try { + return reply.send(await coordinator.appendEvent(userId, sessionId, { + eventId: body.eventId as string | undefined, + type: body.type as typeof VIBE_EVENT_TYPES[number], + trackId: body.trackId as string | undefined, + occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string), + positionMs: body.positionMs as number | undefined, + durationMs: body.durationMs as number | undefined, + payload: body.payload as Record | undefined, + })); + } catch (error) { + return sendCoordinatorError(reply, error); + } + }); + + fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => { + const userId = requireUser(request, reply); + if (!userId) return; + const { sessionId } = request.params as { sessionId: string }; + if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); + try { + return reply.send(await coordinator.end(userId, sessionId)); + } catch (error) { + return sendCoordinatorError(reply, error); + } + }); + + fastify.post('/v2/vibe/sessions/:sessionId/next', async (request, reply) => { + const userId = requireUser(request, reply); + if (!userId) return; + const { sessionId } = request.params as { sessionId: string }; + const body = isObject(request.body) ? request.body : {}; + if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); + if (body.expectedPlanVersion !== undefined + && (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) { + return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' }); + } + try { + const expectedPlanVersion = body.expectedPlanVersion as number | undefined; + return reply.send(expectedPlanVersion === undefined + ? await coordinator.serveNext(userId, sessionId) + : await coordinator.serveNext(userId, sessionId, expectedPlanVersion)); + } catch (error) { + return sendCoordinatorError(reply, error); + } + }); +} + +function sendCoordinatorError(reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }, error: unknown) { + if (error instanceof VibeSessionNotFoundError) return reply.code(404).send({ error: error.message }); + if (error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message }); + if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' }); + throw error; +} diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 6bd5595..c660eeb 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -20,13 +20,18 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType describe('DbService v2 methods', () => { describe('durable Vibe sessions', () => { it('creates, reads, and ends sessions scoped to their user', async () => { - const { service, mockQuery } = makeService(); + const { service, poolQuery, clientQuery } = makeTransactionalService(); const session = { id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null, context: { activity: 'focus' }, policy_version: 'v2.1', }; - mockQuery - .mockResolvedValueOnce({ rows: [session] }) + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // user advisory lock + .mockResolvedValueOnce({ rows: [] }) // active-session lock + .mockResolvedValueOnce({ rows: [session] }) // insert + .mockResolvedValueOnce({ rows: [] }); // COMMIT + poolQuery .mockResolvedValueOnce({ rows: [session] }) .mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] }); @@ -36,11 +41,32 @@ describe('DbService v2 methods', () => { await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session); await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' }); - expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO vibe_sessions'); - expect(mockQuery.mock.calls[0][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']); - expect(mockQuery.mock.calls[1][0]).toContain('id = $1 AND user_id = $2'); - expect(mockQuery.mock.calls[2][0]).toContain('COALESCE(ended_at, NOW())'); - expect(mockQuery.mock.calls[2][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); + expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock'); + expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE"); + expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions'); + expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']); + expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2'); + expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())'); + expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); + }); + + it('replaces an owned active session and writes its terminal event before starting another', async () => { + const { service, clientQuery } = makeTransactionalService(); + const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // user advisory lock + .mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock + .mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace + .mockResolvedValueOnce({ rows: [] }) // terminal event + .mockResolvedValueOnce({ rows: [replacement] }) // new session + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })) + .resolves.toEqual(replacement); + expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'"); + expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'"); + expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions'); }); it('records retry-safe events and reports whether the event was inserted', async () => { @@ -69,12 +95,44 @@ describe('DbService v2 methods', () => { expect(values).toEqual([ 'session-1', 'user-1', ]); - expect(clientQuery.mock.calls).toHaveLength(4); + expect(clientQuery.mock.calls).toHaveLength(5); + expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections'); expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain( expect.stringContaining('UPDATE vibe_sessions') ); }); + it('projects material feedback once with the durable event transaction', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', + user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(), + position_ms: null, duration_ms: null, payload: {}, + }; + const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1'); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) + .mockResolvedValueOnce({ rows: [] }) // no existing idempotency key + .mockResolvedValueOnce({ rows: [event] }) // event insert + .mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker + .mockResolvedValueOnce({ rows: [] }) // play history + .mockResolvedValueOnce({ rows: [] }) // track counter + .mockResolvedValueOnce({ rows: [] }) // session timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', + type: 'completed', trackId: 'track-1', + })).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } }); + + expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([ + expect.stringContaining('vibe_event_projections'), + expect.stringContaining('INSERT INTO play_history'), + ])); + expect(evidence).toHaveBeenCalledTimes(1); + }); + it('rejects an event when no owned session is returned', async () => { const { service, clientQuery } = makeTransactionalService(); clientQuery @@ -114,9 +172,116 @@ describe('DbService v2 methods', () => { expect.stringContaining('INSERT INTO vibe_events') ); }); + + it('locks the terminal transition with its event and makes terminal retries no-ops', async () => { + const { service, clientQuery } = makeTransactionalService(); + const active = { id: 'session-1', user_id: 'user-1', status: 'active' }; + const ended = { ...active, status: 'ended', ended_at: new Date() }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [active] }) // lock + .mockResolvedValueOnce({ rows: [] }) // terminal event + .mockResolvedValueOnce({ rows: [ended] }) // status transition + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.endVibeSessionWithEvent('session-1', 'user-1')) + .resolves.toEqual({ session: ended, ended: true }); + expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE'); + expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'"); + expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'"); + }); + + it('resumes an owned session once and records session_resumed in the same lock', async () => { + const { service, clientQuery } = makeTransactionalService(); + const active = { id: 'session-1', user_id: 'user-1', status: 'active' }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock + .mockResolvedValueOnce({ rows: [] }) // no old resume event + .mockResolvedValueOnce({ rows: [active] }) // activate + .mockResolvedValueOnce({ rows: [] }) // ledger event + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.resumeVibeSession('session-1', 'user-1')) + .resolves.toEqual({ session: active, resumed: true }); + expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE'); + expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'"); + }); }); describe('durable Vibe plans', () => { + it('publishes a revision and its plan_published event in one transaction', async () => { + const { service, clientQuery } = makeTransactionalService(); + const published = { + id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started', + state_snapshot: {}, objective_snapshot: {}, created_at: new Date(), + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock + .mockResolvedValueOnce({ rows: [published] }) // header + .mockResolvedValueOnce({ rows: [] }) // item + .mockResolvedValueOnce({ rows: [] }) // plan_published + .mockResolvedValueOnce({ rows: [] }) // timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.publishVibePlan({ + sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started', + stateSnapshot: {}, objectiveSnapshot: {}, + items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }], + })).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] }); + + expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE'); + expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'"); + expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT'); + }); + + it('rolls back the plan header and items if writing plan_published fails', async () => { + const { service, clientQuery } = makeTransactionalService(); + const published = { + id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started', + state_snapshot: {}, objective_snapshot: {}, created_at: new Date(), + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) + .mockResolvedValueOnce({ rows: [published] }) // header + .mockResolvedValueOnce({ rows: [] }) // item + .mockRejectedValueOnce(new Error('ledger write failed')) + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect(service.publishVibePlan({ + sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started', + stateSnapshot: {}, objectiveSnapshot: {}, + items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }], + })).rejects.toThrow('ledger write failed'); + expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK'); + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT'); + }); + + it('refuses a delayed initial publication after a concurrent start replaced its session', async () => { + const { service, clientQuery } = makeTransactionalService(); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] }) + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect(service.publishVibePlan({ + sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started', + stateSnapshot: {}, objectiveSnapshot: {}, items: [], + })).rejects.toThrow('Cannot publish a plan for replaced Vibe session'); + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions')); + }); + + it('reads every durable session track as a replacement-plan exclusion', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] }); + await expect(service.getVibeSessionTrackIds('session-1', 'user-1')) + .resolves.toEqual(['served', 'skipped', 'disliked']); + expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id'); + expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL'); + }); + it('writes a header and all items in one transaction', async () => { const { service, clientQuery } = makeTransactionalService(); clientQuery @@ -143,6 +308,59 @@ describe('DbService v2 methods', () => { expect(clientQuery.mock.calls[3][0]).toBe('COMMIT'); }); + it('serves and commits one next item under the session lock', async () => { + const { service, clientQuery } = makeTransactionalService(); + const item = { + plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next', + candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan + .mockResolvedValueOnce({ rows: [item] }) // commit item + .mockResolvedValueOnce({ rows: [] }) // track_served event + .mockResolvedValueOnce({ rows: [] }) // timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false }); + expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE'); + expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true'); + expect(clientQuery.mock.calls[4][0]).toContain("'track_served'"); + }); + + it('returns a newer preview signal without committing when the expected plan is stale', async () => { + const { service, clientQuery } = makeTransactionalService(); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1)) + .resolves.toEqual({ item: null, stale: true }); + expect(clientQuery.mock.calls).toHaveLength(4); + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true')); + }); + + it('returns the original item when a version-aware next request is retried', async () => { + const { service, clientQuery } = makeTransactionalService(); + const item = { + plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next', + candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest + .mockResolvedValueOnce({ rows: [item] }) // prior served item + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1)) + .resolves.toEqual({ item, stale: false }); + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true')); + }); + it('reads the latest revision and reconstructs ordered plan items', async () => { const { service, mockQuery } = makeService(); mockQuery.mockResolvedValue({ rows: [{ diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 85cf023..a256ebb 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -1478,10 +1478,17 @@ export class DbService { /** * Create a new session state row. */ - async createSessionState(userId: string, context?: string, stateVector?: Record): Promise { + async createSessionState( + userId: string, + context?: string, + stateVector?: Record, + sessionId?: string + ): Promise { const res = await this.pgClient.query( - `INSERT INTO session_state (user_id, context, state_vector) VALUES ($1, $2, $3) RETURNING session_id`, - [userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}'] + `INSERT INTO session_state (session_id, user_id, context, state_vector) + VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4::jsonb) + RETURNING session_id`, + [sessionId ?? null, userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}'] ); return res.rows[0].session_id as string; } @@ -1508,18 +1515,45 @@ export class DbService { seedTrackId?: string | null; context?: Record; }): Promise { - const res = await this.pgClient.query( - `INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) - VALUES ($1, 'active', $2, $3::jsonb, $4) - RETURNING *`, - [ - params.userId, - params.seedTrackId ?? null, - JSON.stringify(params.context ?? {}), - params.policyVersion, - ] - ); - return res.rows[0] as VibeSession; + return this.withTransaction(async (client) => { + // Serialize starts for one listener even when there is no active row to + // lock yet. The row lock below then safely replaces any prior session. + await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [params.userId]); + // Lock every active session first. This makes concurrent starts converge + // on one active durable session instead of creating overlapping streams. + const active = await client.query( + `SELECT id FROM vibe_sessions WHERE user_id = $1 AND status = 'active' FOR UPDATE`, + [params.userId], + ); + if (active.rows.length > 0) { + const replaced = await client.query( + `UPDATE vibe_sessions + SET status = 'replaced', ended_at = NOW(), last_event_at = NOW() + WHERE user_id = $1 AND status = 'active' + RETURNING id`, + [params.userId], + ); + for (const session of replaced.rows as Array<{ id: string }>) { + await client.query( + `INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload) + VALUES ($1, $2, 'session_ended', NOW(), '{"reason":"replaced"}'::jsonb)`, + [session.id, params.userId], + ); + } + } + const res = await client.query( + `INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) + VALUES ($1, 'active', $2, $3::jsonb, $4) + RETURNING *`, + [ + params.userId, + params.seedTrackId ?? null, + JSON.stringify(params.context ?? {}), + params.policyVersion, + ], + ); + return res.rows[0] as VibeSession; + }); } /** Fetch a Vibe session only when it belongs to the requesting user. */ @@ -1552,6 +1586,70 @@ export class DbService { return (res.rows[0] as VibeSession) ?? null; } + /** + * Resume an owned paused/active session exactly once. The row lock makes + * the transition and its ledger entry inseparable and prevents retries from + * manufacturing a stream of session_resumed events. + */ + async resumeVibeSession(sessionId: string, userId: string): Promise<{ session: VibeSession; resumed: boolean }> { + return this.withTransaction(async (client) => { + const result = await client.query( + `SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [sessionId, userId] + ); + const session = result.rows[0] as VibeSession | undefined; + if (!session) throw new Error('Vibe session was not found or is not owned by this user'); + if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') { + throw new Error(`Cannot resume ${session.status} Vibe session`); + } + + const prior = await client.query( + `SELECT 1 FROM vibe_events WHERE session_id = $1 AND type = 'session_resumed' LIMIT 1`, + [sessionId] + ); + if (prior.rowCount) return { session, resumed: false }; + + const updated = await client.query( + `UPDATE vibe_sessions SET status = 'active', ended_at = NULL, last_event_at = NOW() + WHERE id = $1 RETURNING *`, + [sessionId] + ); + const resumed = updated.rows[0] as VibeSession; + await client.query( + `INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload) + VALUES ($1, $2, 'session_resumed', NOW(), '{}'::jsonb)`, + [sessionId, userId] + ); + return { session: resumed, resumed: true }; + }); + } + + /** End a session and append its terminal event under one session-row lock. */ + async endVibeSessionWithEvent(sessionId: string, userId: string): Promise<{ session: VibeSession; ended: boolean }> { + return this.withTransaction(async (client) => { + const result = await client.query( + `SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [sessionId, userId] + ); + const session = result.rows[0] as VibeSession | undefined; + if (!session) throw new Error('Vibe session was not found or is not owned by this user'); + if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') { + return { session, ended: false }; + } + await client.query( + `INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload) + VALUES ($1, $2, 'session_ended', NOW(), '{}'::jsonb)`, + [sessionId, userId] + ); + const updated = await client.query( + `UPDATE vibe_sessions SET status = 'ended', ended_at = NOW(), last_event_at = NOW() + WHERE id = $1 RETURNING *`, + [sessionId] + ); + return { session: updated.rows[0] as VibeSession, ended: true }; + }); + } + /** * Append an immutable Vibe event. A supplied clientEventId is idempotent per * session: a retry returns the original event and does not advance the @@ -1596,6 +1694,7 @@ export class DbService { ); const existing = existingRes.rows[0] as VibeEvent | undefined; if (existing) { + await this.projectVibeFeedback(existing, client); return { event: existing, inserted: false }; } } @@ -1627,6 +1726,8 @@ export class DbService { throw new Error('Vibe event could not be recorded'); } + await this.projectVibeFeedback(event, client); + await client.query( `UPDATE vibe_sessions SET last_event_at = GREATEST(last_event_at, $2::timestamptz) @@ -1637,11 +1738,178 @@ export class DbService { }); } + /** + * Materialize Vibe feedback into the listener inputs used by the incumbent + * director. The projection marker and every write share the event's + * transaction, so a retry either sees the completed projection or performs + * it once; it can never double-count a completed/skip/dislike/kept signal. + */ + private async projectVibeFeedback(event: VibeEvent, client: PoolClient): Promise { + if (!event.track_id || !['completed', 'skipped', 'disliked', 'kept'].includes(event.type)) return; + const projection = await client.query( + `INSERT INTO vibe_event_projections (event_id) + VALUES ($1) + ON CONFLICT (event_id) DO NOTHING + RETURNING event_id`, + [event.id], + ); + if (!projection.rows[0]) return; + + const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString(); + switch (event.type) { + case 'completed': + await client.query( + `INSERT INTO play_history (user_id, track_id, completed, played_at) + VALUES ($1, $2, true, $3::timestamptz)`, + [event.user_id, event.track_id, occurredAt], + ); + await client.query( + `UPDATE tracks + SET play_count = play_count + 1, last_played_at = $2::timestamptz + WHERE id = $1`, + [event.track_id, occurredAt], + ); + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: event.track_id, + signal: 'playback_completed', + profile: 'longterm', + weight: 0.10, + context: { vibe_event_id: event.id, session_id: event.session_id }, + }, client); + break; + case 'skipped': + await client.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [event.track_id]); + await client.query( + `INSERT INTO feedback (user_id, track_id, action, created_at) + VALUES ($1, $2, 'skipped', $3::timestamptz)`, + [event.user_id, event.track_id, occurredAt], + ); + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: event.track_id, + signal: 'skip_quick', + profile: 'negative', + weight: -0.20, + context: { vibe_event_id: event.id, session_id: event.session_id }, + }, client); + break; + case 'disliked': + await client.query('UPDATE tracks SET dislike_count = dislike_count + 1 WHERE id = $1', [event.track_id]); + await client.query( + `INSERT INTO feedback (user_id, track_id, action, created_at) + VALUES ($1, $2, 'disliked', $3::timestamptz)`, + [event.user_id, event.track_id, occurredAt], + ); + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: event.track_id, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + context: { vibe_event_id: event.id, session_id: event.session_id }, + }, client); + break; + case 'kept': + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: event.track_id, + signal: 'kept', + profile: 'longterm', + weight: 0.05, + context: { vibe_event_id: event.id, session_id: event.session_id }, + }, client); + break; + } + } + /** * Persist one complete revision of a session plan atomically. The caller * supplies the monotonically increasing version; session-level scheduling * will own version allocation when the director is migrated to this ledger. */ + async publishVibePlan(params: { + sessionId: string; + userId: string; + /** Supplying one is for the first revision; otherwise allocate the next. */ + version?: number; + reason: string; + stateSnapshot: Record; + objectiveSnapshot: Record; + items: Array>; + }): Promise { + return this.withTransaction(async (client) => { + // The session lock is also the concurrency boundary for starts/ends and + // plan revisions. In particular, a slow initial planner cannot publish + // into a session a newer start has already replaced. + const sessionRes = await client.query( + `SELECT id, status FROM vibe_sessions + WHERE id = $1 AND user_id = $2 + FOR UPDATE`, + [params.sessionId, params.userId], + ); + const session = sessionRes.rows[0] as Pick | undefined; + if (!session) throw new Error('Vibe session was not found or is not owned by this user'); + if (session.status !== 'active') { + throw new Error(`Cannot publish a plan for ${session.status} Vibe session`); + } + const version = params.version ?? Number((await client.query( + `SELECT COALESCE(MAX(version), 0) + 1 AS version + FROM vibe_plan_versions WHERE session_id = $1`, + [params.sessionId], + )).rows[0].version); + const header = await client.query( + `INSERT INTO vibe_plan_versions + (session_id, version, reason, state_snapshot, objective_snapshot) + VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) + RETURNING *`, + [ + params.sessionId, + version, + params.reason, + JSON.stringify(params.stateSnapshot), + JSON.stringify(params.objectiveSnapshot), + ], + ); + const planVersion = header.rows[0] as VibePlan | undefined; + if (!planVersion) throw new Error('Vibe plan could not be published'); + for (const item of params.items) { + await client.query( + `INSERT INTO vibe_plan_items + (plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`, + [ + planVersion.id, item.ordinal, item.track_id, item.slot_role, + item.candidate_source, item.score, JSON.stringify(item.score_breakdown), + JSON.stringify(item.explanation), item.committed, + ], + ); + } + // Header/items and this ledger event deliberately commit together. A + // client retry can therefore find either neither or the same canonical + // revision; it can never observe a published event without its plan. + await client.query( + `INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload) + VALUES ($1, $2, 'plan_published', NOW(), $3::jsonb)`, + [params.sessionId, params.userId, JSON.stringify({ + planVersion: planVersion.version, + planVersionId: planVersion.id, + reason: params.reason, + itemCount: params.items.length, + feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null, + })], + ); + await client.query( + `UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, + [params.sessionId], + ); + return { + ...planVersion, + items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })), + }; + }); + } + async persistVibePlan(params: { sessionId: string; userId: string; @@ -1696,6 +1964,115 @@ export class DbService { }); } + /** Allocate and persist the next immutable revision while holding the session lock. */ + async persistNextVibePlan(params: Omit[0], 'version'>): Promise { + return this.withTransaction(async (client) => { + const session = await client.query( + `SELECT id, status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [params.sessionId, params.userId] + ); + if (!session.rows[0]) throw new Error('Vibe session was not found or is not owned by this user'); + if ((session.rows[0] as Pick).status !== 'active') { + throw new Error(`Cannot record a new event for ${(session.rows[0] as Pick).status} Vibe session`); + } + const versionResult = await client.query( + `SELECT COALESCE(MAX(version), 0) + 1 AS version FROM vibe_plan_versions WHERE session_id = $1`, + [params.sessionId] + ); + const version = Number(versionResult.rows[0].version); + const header = await client.query( + `INSERT INTO vibe_plan_versions + (session_id, version, reason, state_snapshot, objective_snapshot) + VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) RETURNING *`, + [params.sessionId, version, params.reason, JSON.stringify(params.stateSnapshot), JSON.stringify(params.objectiveSnapshot)] + ); + const planVersion = header.rows[0] as VibePlan; + for (const item of params.items) { + await client.query( + `INSERT INTO vibe_plan_items + (plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`, + [planVersion.id, item.ordinal, item.track_id, item.slot_role, item.candidate_source, + item.score, JSON.stringify(item.score_breakdown), JSON.stringify(item.explanation), item.committed] + ); + } + return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) }; + }); + } + + /** + * Atomically commit one item from the latest plan. A version-aware request + * acts as an idempotency key: retrying the same expected version receives + * the original item, while a replaced plan is returned as stale without + * committing any old item. Calls without an expected version preserve the + * original legacy cursor behaviour. + */ + async serveNextVibePlanItem( + sessionId: string, + userId: string, + expectedPlanVersion?: number, + ): Promise<{ item: VibePlanItem | null; stale: boolean }> { + return this.withTransaction(async (client) => { + const session = await client.query( + `SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [sessionId, userId] + ); + const row = session.rows[0] as Pick | undefined; + if (!row) throw new Error('Vibe session was not found or is not owned by this user'); + if (row.status !== 'active') throw new Error(`Cannot record a new event for ${row.status} Vibe session`); + const latest = await client.query( + `SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`, + [sessionId], + ); + const plan = latest.rows[0] as Pick | undefined; + if (!plan) return { item: null, stale: false }; + if (expectedPlanVersion !== undefined && expectedPlanVersion !== plan.version) { + return { item: null, stale: true }; + } + + if (expectedPlanVersion !== undefined) { + const prior = await client.query( + `SELECT i.* + FROM vibe_events e + JOIN vibe_plan_items i + ON i.plan_version_id = (e.payload->>'planVersionId')::uuid + AND i.ordinal = (e.payload->>'ordinal')::integer + WHERE e.session_id = $1 + AND e.type = 'track_served' + AND e.payload->>'planVersion' = $2::text + ORDER BY e.occurred_at ASC + LIMIT 1`, + [sessionId, expectedPlanVersion], + ); + const servedPreviously = prior.rows[0] as VibePlanItem | undefined; + if (servedPreviously) return { item: servedPreviously, stale: false }; + } + const item = await client.query( + `WITH next_item AS ( + SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i + WHERE i.plan_version_id = $2 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE + ) + UPDATE vibe_plan_items i SET committed = true + FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal + RETURNING i.*`, + [sessionId, plan.id] + ); + const served = item.rows[0] as VibePlanItem | undefined; + if (!served) return { item: null, stale: false }; + await client.query( + `INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload) + VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`, + [sessionId, userId, served.track_id, JSON.stringify({ + planVersion: plan.version, + planVersionId: served.plan_version_id, + ordinal: served.ordinal, + })] + ); + await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]); + return { item: served, stale: false }; + }); + } + /** Read a specific plan revision, or the latest revision for a session. */ async getVibePlan(sessionId: string, userId: string, version?: number): Promise { const res = await this.pgClient.query( @@ -1745,6 +2122,41 @@ export class DbService { return plan; } + /** Return the replacement revision caused by a material feedback event. */ + async getVibePlanForFeedbackEvent(sessionId: string, userId: string, eventId: string): Promise { + const res = await this.pgClient.query( + `SELECT version + FROM vibe_plan_versions p + JOIN vibe_sessions s ON s.id = p.session_id + WHERE p.session_id = $1 + AND s.user_id = $2 + AND p.objective_snapshot->>'feedbackEventId' = $3 + ORDER BY p.version DESC + LIMIT 1`, + [sessionId, userId, eventId], + ); + const version = res.rows[0]?.version as number | undefined; + return version === undefined ? null : this.getVibePlan(sessionId, userId, version); + } + + /** + * Tracks exposed by a durable session are never eligible for another + * revision of that same session. This includes served items and every + * explicit feedback target, not just completed play history. + */ + async getVibeSessionTrackIds(sessionId: string, userId: string): Promise { + const res = await this.pgClient.query( + `SELECT DISTINCT e.track_id + FROM vibe_events e + JOIN vibe_sessions s ON s.id = e.session_id + WHERE e.session_id = $1 + AND s.user_id = $2 + AND e.track_id IS NOT NULL`, + [sessionId, userId], + ); + return res.rows.map((row: { track_id: string }) => row.track_id); + } + /** * Upsert a diversity budget for a user. */ diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 0f1c1cf..296c5e0 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -835,6 +835,11 @@ export class SessionDirector { seedTrackId?: string, options: PlanBuildOptions = {} ): Promise { + // Durable events outlive any process-local queue. Fetch them here rather + // than trusting callers to remember the boundary, so a served, skipped, + // disliked, or otherwise exposed track can never leak into a replacement + // revision for this session. + const durableSessionTrackIds = await this.db.getVibeSessionTrackIds(sessionId, userId); // Do not let abundant track-level beliefs crowd out the artist/genre // affinities required by the discovery generators. const beliefGroups = await Promise.all([ @@ -895,6 +900,7 @@ export class SessionDirector { // a replacement plan while its Vibe session is active. const recentExclusionSet = new Set([ ...recentPlays.map(p => p.trackId), + ...durableSessionTrackIds, ...(options.excludedTrackIds ?? []), ]); if (seedTrackId) recentExclusionSet.add(seedTrackId); diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts new file mode 100644 index 0000000..bd05625 --- /dev/null +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DbService } from './db.service.js'; +import { + DEFAULT_VIBE_POLICY_VERSION, + VibeSessionCoordinator, + VibeSessionLifecycleError, + VibePlanNotFoundError, +} from './vibe-session-coordinator.service.js'; + +const SESSION_ID = '11111111-1111-4111-8111-111111111111'; +const TRACK_ID = '22222222-2222-4222-8222-222222222222'; +const EVENT_ID = '33333333-3333-4333-8333-333333333333'; + +function session(status: 'active' | 'ended' = 'active') { + return { + id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null, + context: { activity: 'focus' }, policy_version: DEFAULT_VIBE_POLICY_VERSION, + started_at: new Date('2026-01-01T00:00:00.000Z'), + last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null, + } as any; +} + +function plan() { + return { + id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started', + state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(), + items: [{ + plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next', + candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 }, + explanation: [], committed: false, + }], + } as any; +} + +function setup() { + const db = { + createVibeSession: vi.fn().mockResolvedValue(session()), + createSessionState: vi.fn().mockResolvedValue(SESSION_ID), + recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }), + persistVibePlan: vi.fn().mockResolvedValue(plan()), + publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({ + ...plan(), version: input.version ?? 2, reason: input.reason, + })), + getVibeSession: vi.fn().mockResolvedValue(session()), + getVibePlan: vi.fn().mockResolvedValue(plan()), + endVibeSession: vi.fn().mockResolvedValue(session('ended')), + endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }), + resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }), + serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }), + persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }), + getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null), + } as unknown as DbService; + const director = { + buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]), + buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }), + } as any; + return { db, director, coordinator: new VibeSessionCoordinator(db, director) }; +} + +describe('VibeSessionCoordinator', () => { + it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => { + const { db, director, coordinator } = setup(); + + const response = await coordinator.start('user-1', { + context: { activity: 'focus' }, intent: 'deep-work', + }); + + expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } }); + expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION, + })); + expect(db.createSessionState).toHaveBeenCalledWith('user-1', 'focus', expect.any(Object), SESSION_ID); + expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined); + expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: SESSION_ID, version: 1, reason: 'session_started', + items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })], + })); + expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type)) + .toEqual(['session_started']); + }); + + it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => { + const { db, coordinator } = setup(); + (db.recordVibeEvent as any).mockResolvedValueOnce({ + event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false, + }); + (db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 }); + + const response = await coordinator.appendEvent('user-1', SESSION_ID, { + eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID, + }); + + expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null }); + expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID })); + expect(db.persistNextVibePlan).not.toHaveBeenCalled(); + }); + + it('recovers a material feedback replan when its first persistence attempt failed', async () => { + const { db, coordinator } = setup(); + (db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure')); + await expect(coordinator.appendEvent('user-1', SESSION_ID, { + eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID, + })).rejects.toThrow('temporary database failure'); + + (db.recordVibeEvent as any).mockResolvedValueOnce({ + event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false, + }); + const recovered = await coordinator.appendEvent('user-1', SESSION_ID, { + eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID, + }); + + expect(db.publishVibePlan).toHaveBeenCalledTimes(2); + expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 }); + }); + + it('persists a replacement revision for material feedback and returns its preview', async () => { + const { db, director, coordinator } = setup(); + const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); + + expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, TRACK_ID); + expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })], + })); + expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); + }); + + it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => { + const { db, director, coordinator } = setup(); + const seedTrackId = '44444444-4444-4444-8444-444444444444'; + (db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId }); + + await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); + + expect(director.buildPlan).toHaveBeenCalledWith( + 'user-1', + SESSION_ID, + TRACK_ID, + { excludedTrackIds: new Set([seedTrackId]) }, + ); + }); + + it('resumes only the caller-owned session and serves a plan item through the durable API', async () => { + const { db, coordinator } = setup(); + const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID }); + const served = await coordinator.serveNext('user-1', SESSION_ID); + + expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1'); + expect(resumed.sessionId).toBe(SESSION_ID); + expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1'); + expect(served.now).toMatchObject({ track_id: TRACK_ID }); + }); + + it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => { + const { db, coordinator } = setup(); + (db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session')); + + await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' })) + .rejects.toBeInstanceOf(VibeSessionLifecycleError); + }); + + it('distinguishes a missing requested revision from an empty latest plan', async () => { + const { db, coordinator } = setup(); + (db.getVibePlan as any).mockResolvedValueOnce(null); + await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError); + }); + + it('returns 404-worthy failure when a session has no latest plan', async () => { + const { db, coordinator } = setup(); + (db.getVibePlan as any).mockResolvedValueOnce(null); + await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError); + }); + + it('does not commit a stale version-aware next request and returns the current preview', async () => { + const { db, coordinator } = setup(); + (db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true }); + const result = await coordinator.serveNext('user-1', SESSION_ID, 1); + + expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1); + expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } }); + }); + + it('ends an active session once and preserves its latest persisted plan', async () => { + const { db, coordinator } = setup(); + const response = await coordinator.end('user-1', SESSION_ID); + + expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1'); + expect(response.session.status).toBe('ended'); + expect(response.planVersion).toBe(1); + }); + + it('does not publish an initial plan when another start replaced the session while planning', async () => { + const { db, coordinator } = setup(); + (db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session')); + + await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError); + expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 })); + }); +}); diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts new file mode 100644 index 0000000..79efeaa --- /dev/null +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -0,0 +1,293 @@ +import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; +import { SessionDirector } from './session-director.service.js'; + +/** + * This is deliberately a narrow bridge between the durable Vibe ledger and + * the current deterministic director. It writes authoritative revisions and + * lets the playback client replace only its unserved preview after feedback. + */ +export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial'; + +export const VIBE_EVENT_TYPES = [ + 'session_started', 'session_resumed', 'session_ended', 'context_changed', + 'plan_published', 'track_served', 'playback_started', 'progress', 'completed', + 'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed', + 'manual_search', 'album_opened', 'artist_opened', 'playlist_added', + 'track_replayed', 'volume_changed', 'playback_error', +] as const; + +export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number]; + +export interface StartVibeSessionInput { + seedTrackId?: string; + context?: Record; + intent?: string; + policyVersion?: string; + resumeSessionId?: string; +} + +export interface AppendVibeEventInput { + eventId?: string; + type: VibeEventType; + trackId?: string; + occurredAt?: Date; + positionMs?: number; + durationMs?: number; + payload?: Record; +} + +export class VibeSessionNotFoundError extends Error {} +export class VibeSessionLifecycleError extends Error {} +export class VibePlanNotFoundError extends Error {} + +export interface VibeSessionResponse { + session: VibeSession; + sessionId: string; + planVersion: number | null; + now: VibePlan['items'][number] | null; + preview: VibePlan['items']; + state: Record; + replanned: boolean; + replanReason: string | null; +} + +export class VibeSessionCoordinator { + constructor( + private readonly db: DbService, + private readonly director: Pick, + ) {} + + async start(userId: string, input: StartVibeSessionInput): Promise { + if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); + const context = input.context ?? {}; + const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION; + const session = await this.db.createVibeSession({ + userId, + policyVersion, + seedTrackId: input.seedTrackId ?? null, + context, + }); + + // session_state is a derived cache used by the current director. Give it + // the durable ID so director state cannot accidentally bleed into another + // session while the durable tables remain the source of truth. + await this.db.createSessionState( + userId, + typeof context.activity === 'string' ? context.activity : input.intent, + { energy: 0.5, noveltyHunger: 0.3 }, + session.id, + ); + await this.db.recordVibeEvent({ + sessionId: session.id, + userId, + type: 'session_started', + payload: { policyVersion, context, intent: input.intent ?? null }, + }); + + const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId); + const state = await this.director.buildState(userId, session.id); + let plan: VibePlan; + try { + plan = await this.db.publishVibePlan({ + sessionId: session.id, + userId, + version: 1, + reason: 'session_started', + stateSnapshot: state, + objectiveSnapshot: { + policyVersion, + intent: input.intent ?? null, + horizonTracks: candidates.length, + }, + items: candidates.map((candidate, ordinal) => ({ + ordinal, + track_id: candidate.trackId, + slot_role: ordinal === 0 ? 'next' : null, + candidate_source: candidate.generatorId, + score: candidate.relevance, + score_breakdown: { relevance: candidate.relevance }, + explanation: candidate.explanation, + committed: false, + })), + }); + } catch (error) { + throw this.mapLifecycleError(error); + } + + return this.toResponse(session, plan, state); + } + + async getPlan(userId: string, sessionId: string, version?: number): Promise { + const session = await this.requireSession(userId, sessionId); + const plan = await this.db.getVibePlan(sessionId, userId, version); + if (!plan) throw new VibePlanNotFoundError( + version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found', + ); + return this.toResponse(session, plan, plan?.state_snapshot ?? {}); + } + + async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise { + try { + const served = expectedPlanVersion === undefined + ? await this.db.serveNextVibePlanItem(sessionId, userId) + : await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion); + const response = await this.getPlan(userId, sessionId); + // A plan may be replaced between the client's preview and this request. + // In that case the database does not commit anything and this is the + // current, revisable preview the client must reconcile to. + if (served.stale) return response; + return { ...response, now: served.item, preview: response.preview }; + } catch (error) { + throw this.mapLifecycleError(error); + } + } + + async appendEvent( + userId: string, + sessionId: string, + input: AppendVibeEventInput, + ): Promise { + try { + const result = await this.db.recordVibeEvent({ + sessionId, + userId, + clientEventId: input.eventId, + type: input.type, + trackId: input.trackId, + occurredAt: input.occurredAt, + positionMs: input.positionMs, + durationMs: input.durationMs, + payload: input.payload, + }); + if (!isMaterialFeedback(input.type)) { + const response = await this.getPlan(userId, sessionId); + return { ...response, event: result.event, idempotent: !result.inserted }; + } + + // A material event is durable before its computed replacement can be + // written. If planning/persistence failed after that event committed, a + // retry must finish the missing replacement instead of permanently + // returning an obsolete preview. Once a replacement exists, a duplicate + // retry returns that canonical revision without doing work again. + if (!result.inserted) { + const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id); + if (existingReplacement) { + const session = await this.requireSession(userId, sessionId); + return { + ...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot), + event: result.event, + idempotent: true, + }; + } + } + + const session = await this.requireSession(userId, sessionId); + const state = await this.director.buildState(userId, sessionId); + // A feedback target is useful as the local replan anchor, but it must + // never displace the durable seed from the exclusion boundary. Unlike + // feedback tracks, the seed is not necessarily present in the event + // ledger, so carry it explicitly into every replacement request. + const seedTrackId = session.seed_track_id ?? undefined; + const candidates = seedTrackId + ? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, { + excludedTrackIds: new Set([seedTrackId]), + }) + : await this.director.buildPlan(userId, sessionId, input.trackId); + const reason = `feedback:${input.type}`; + const plan = await this.db.publishVibePlan({ + sessionId, + userId, + reason, + stateSnapshot: state, + objectiveSnapshot: { + policyVersion: session.policy_version, + feedbackEventId: result.event.id, + feedbackType: input.type, + horizonTracks: candidates.length, + }, + items: candidates.map((candidate, ordinal) => ({ + ordinal, + track_id: candidate.trackId, + slot_role: ordinal === 0 ? 'next' : null, + candidate_source: candidate.generatorId, + score: candidate.relevance, + score_breakdown: { relevance: candidate.relevance }, + explanation: candidate.explanation, + committed: false, + })), + }); + return { + ...this.toResponse(session, plan, state), + event: result.event, + idempotent: !result.inserted, + replanned: true, + replanReason: reason, + }; + } catch (error) { + throw this.mapLifecycleError(error); + } + } + + async end(userId: string, sessionId: string): Promise { + let ended: VibeSession; + try { + ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session; + } catch (error) { + throw this.mapLifecycleError(error); + } + const plan = await this.db.getVibePlan(sessionId, userId); + return this.toResponse(ended, plan, plan?.state_snapshot ?? {}); + } + + private async resume(userId: string, sessionId: string): Promise { + try { + const resumed = await this.db.resumeVibeSession(sessionId, userId); + const plan = await this.db.getVibePlan(sessionId, userId); + return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {}); + } catch (error) { + throw this.mapLifecycleError(error); + } + } + + private async requireSession(userId: string, sessionId: string): Promise { + const session = await this.db.getVibeSession(sessionId, userId); + if (!session) throw new VibeSessionNotFoundError('Vibe session was not found'); + return session; + } + + private toResponse( + session: VibeSession, + plan: VibePlan | null, + state: Record, + ): VibeSessionResponse { + // A revision is immutable, but clients need a live future: already served + // rows stay in the ledger and are excluded from the replacement preview. + const preview = plan?.items.filter((item) => !item.committed).slice(0, 8) ?? []; + return { + session, + sessionId: session.id, + planVersion: plan?.version ?? null, + now: preview[0] ?? null, + preview, + state, + replanned: false, + replanReason: null, + }; + } + + private mapLifecycleError(error: unknown): Error { + if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) { + return new VibeSessionLifecycleError(error.message); + } + if (error instanceof Error && error.message.includes('not found or is not owned')) { + return new VibeSessionNotFoundError('Vibe session was not found'); + } + return error instanceof Error ? error : new Error(String(error)); + } +} + +const MATERIAL_FEEDBACK_EVENTS = new Set(['skipped', 'disliked', 'completed', 'kept']); + +function isMaterialFeedback(type: VibeEventType): boolean { + return MATERIAL_FEEDBACK_EVENTS.has(type); +} From 57df1cfe9fb065bebf2d915c3e520b9c789d6b2b Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 23:47:02 +0400 Subject: [PATCH 4/8] feat(vibe): reconcile mutable session previews in playback --- README.md | 5 + .../src/routes/vibe-sessions.routes.test.ts | 28 ++ backend/src/routes/vibe-sessions.routes.ts | 25 + backend/src/services/db.service.test.ts | 89 ++++ backend/src/services/db.service.ts | 147 ++++++ .../vibe-session-coordinator.service.test.ts | 20 + .../vibe-session-coordinator.service.ts | 28 ++ docker-compose.yml | 4 + frontend/src/components/AudioEngine.test.tsx | 45 ++ frontend/src/components/AudioEngine.tsx | 62 +-- frontend/src/components/TrackRow.tsx | 13 +- frontend/src/components/VibeTimeline.test.tsx | 41 ++ frontend/src/components/VibeTimeline.tsx | 1 + frontend/src/pages/Vibe.tsx | 124 +---- frontend/src/services/vibeService.test.ts | 55 ++- frontend/src/services/vibeService.ts | 137 +++--- frontend/src/services/vibeSession.test.ts | 260 ++++++++-- frontend/src/services/vibeSession.ts | 445 ++++++++++++++++-- frontend/src/store/usePlaybackStore.ts | 44 ++ frontend/src/store/useVibeStore.ts | 50 +- 20 files changed, 1311 insertions(+), 312 deletions(-) create mode 100644 frontend/src/components/AudioEngine.test.tsx create mode 100644 frontend/src/components/VibeTimeline.test.tsx diff --git a/README.md b/README.md index 26afe18..ec7d06a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ A high-performance, distributed music orchestration and recommendation platform. - Docker & Docker Compose ### Running Locally + +Set `MUZICK_VIBE_USER_ID` in `.env` to the UUID of the local Muzick user before +using Vibe. Durable Vibe session routes intentionally reject client-supplied +identities, so this is the trusted single-user binding for a self-hosted stack. + ```bash docker-compose up -d ``` diff --git a/backend/src/routes/vibe-sessions.routes.test.ts b/backend/src/routes/vibe-sessions.routes.test.ts index 0d03bf2..a9300b5 100644 --- a/backend/src/routes/vibe-sessions.routes.test.ts +++ b/backend/src/routes/vibe-sessions.routes.test.ts @@ -22,6 +22,7 @@ async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }), end: vi.fn().mockResolvedValue(response()), serveNext: vi.fn().mockResolvedValue(response()), + advancePastUnplayable: vi.fn().mockResolvedValue(response()), } as any; const app = Fastify(); await app.register(vibeSessionsRoutes, { coordinator, identityResolver }); @@ -133,4 +134,31 @@ describe('durable Vibe session routes', () => { expect(coordinator.serveNext).toHaveBeenCalledTimes(1); await app.close(); }); + + it('uses the explicit versioned advancement protocol for a served unplayable item', async () => { + const { app, coordinator } = await appWithCoordinator(); + const result = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, + payload: { + expectedPlanVersion: 2, + unplayable: { + eventId: '33333333-3333-4333-8333-333333333333', + planVersionId: '44444444-4444-4444-8444-444444444444', + ordinal: 0, + trackId: TRACK_ID, + }, + }, + }); + + expect(result.statusCode).toBe(200); + expect(coordinator.advancePastUnplayable).toHaveBeenCalledWith(USER_ID, SESSION_ID, { + expectedPlanVersion: 2, + eventId: '33333333-3333-4333-8333-333333333333', + planVersionId: '44444444-4444-4444-8444-444444444444', + ordinal: 0, + trackId: TRACK_ID, + }); + expect(coordinator.serveNext).not.toHaveBeenCalled(); + await app.close(); + }); }); diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index 44762e9..a477340 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -156,8 +156,33 @@ export default async function vibeSessionsRoutes( && (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) { return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' }); } + const unplayable = body.unplayable; + if (unplayable !== undefined && !isObject(unplayable)) { + return reply.code(400).send({ error: 'unplayable must be an object' }); + } + if (isObject(unplayable)) { + if (body.expectedPlanVersion === undefined) { + return reply.code(400).send({ error: 'expectedPlanVersion is required when advancing an unplayable item' }); + } + if (!validUuid(unplayable.eventId) + || !validUuid(unplayable.planVersionId) + || !validUuid(unplayable.trackId) + || !Number.isInteger(unplayable.ordinal) + || (unplayable.ordinal as number) < 0) { + return reply.code(400).send({ error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' }); + } + } try { const expectedPlanVersion = body.expectedPlanVersion as number | undefined; + if (isObject(unplayable)) { + return reply.send(await coordinator.advancePastUnplayable(userId, sessionId, { + expectedPlanVersion: expectedPlanVersion as number, + eventId: unplayable.eventId as string, + planVersionId: unplayable.planVersionId as string, + ordinal: unplayable.ordinal as number, + trackId: unplayable.trackId as string, + })); + } return reply.send(expectedPlanVersion === undefined ? await coordinator.serveNext(userId, sessionId) : await coordinator.serveNext(userId, sessionId, expectedPlanVersion)); diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index c660eeb..8ce3ace 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -361,6 +361,95 @@ describe('DbService v2 methods', () => { expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true')); }); + it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => { + const { service, clientQuery } = makeTransactionalService(); + const replacement = { + plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null, + candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan + .mockResolvedValueOnce({ rows: [] }) // no prior playback_error event + .mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor + .mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event + .mockResolvedValueOnce({ rows: [replacement] }) // commit replacement + .mockResolvedValueOnce({ rows: [] }) // replacement track_served event + .mockResolvedValueOnce({ rows: [] }) // playback_error result payload + .mockResolvedValueOnce({ rows: [] }) // session timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', { + expectedPlanVersion: 1, + planVersionId: 'plan-1', + ordinal: 0, + trackId: 'track-1', + eventId: 'event-1', + })).resolves.toEqual({ item: replacement, stale: false }); + + expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'"); + expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true'); + expect(clientQuery.mock.calls[7][0]).toContain("'track_served'"); + expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload'); + }); + + it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => { + const { service, clientQuery } = makeTransactionalService(); + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan + .mockResolvedValueOnce({ rows: [] }) // no prior playback_error event + // The lower-ordinal event can have a lexically greater UUID at the + // same occurred_at. The cursor must still be the highest immutable + // plan ordinal, never whichever UUID sorts last. + .mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor + .mockResolvedValueOnce({ rows: [] }); // ROLLBACK + + await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', { + expectedPlanVersion: 1, + planVersionId: 'plan-1', + ordinal: 0, + trackId: 'track-1', + eventId: 'event-1', + })).rejects.toThrow('not the current served cursor'); + + expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items'); + expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC'); + expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC'); + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true')); + }); + + it('retries an unplayable advancement with the same event id without consuming another item', async () => { + const { service, clientQuery } = makeTransactionalService(); + const replacement = { + plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null, + candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock + .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan + .mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: { + planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1', + advancedTo: { planVersionId: 'plan-1', ordinal: 1 }, + } }] }) // prior explicit advancement + .mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', { + expectedPlanVersion: 1, + planVersionId: 'plan-1', + ordinal: 0, + trackId: 'track-1', + eventId: 'event-1', + })).resolves.toEqual({ item: replacement, stale: false }); + + expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true')); + expect(clientQuery.mock.calls).toHaveLength(6); + }); + it('reads the latest revision and reconstructs ordered plan items', async () => { const { service, mockQuery } = makeService(); mockQuery.mockResolvedValue({ rows: [{ diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index a256ebb..7c3cb60 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -2073,6 +2073,153 @@ export class DbService { }); } + /** + * Advance an already-served item which the player could not resolve (for + * example, its file was hidden after the revision was published). Unlike a + * normal version-aware /next retry, this has a distinct client event id and + * therefore intentionally moves beyond the item previously served for that + * revision. The event records both the rejected item and the replacement so + * a lost response can be retried without consuming another plan item. + */ + async advancePastUnplayableVibePlanItem( + sessionId: string, + userId: string, + input: { + expectedPlanVersion: number; + planVersionId: string; + ordinal: number; + trackId: string; + eventId: string; + }, + ): Promise<{ item: VibePlanItem | null; stale: boolean }> { + return this.withTransaction(async (client) => { + const session = await client.query( + `SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [sessionId, userId], + ); + const sessionRow = session.rows[0] as Pick | undefined; + if (!sessionRow) throw new Error('Vibe session was not found or is not owned by this user'); + + const latest = await client.query( + `SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`, + [sessionId], + ); + const plan = latest.rows[0] as Pick | undefined; + if (!plan) return { item: null, stale: false }; + if (plan.version !== input.expectedPlanVersion || plan.id !== input.planVersionId) { + return { item: null, stale: true }; + } + + // Idempotency is scoped to this explicit advancement operation, rather + // than overloading the version-aware /next retry which must keep + // returning the originally served item. + const prior = await client.query( + `SELECT type, payload + FROM vibe_events + WHERE session_id = $1 AND client_event_id = $2::uuid`, + [sessionId, input.eventId], + ); + const priorEvent = prior.rows[0] as Pick | undefined; + if (priorEvent && priorEvent.type !== 'playback_error') { + throw new Error('Vibe client event id was already used for a different event'); + } + const priorPayload = priorEvent?.payload as Record | undefined; + if (priorPayload) { + if (priorPayload.planVersionId !== input.planVersionId + || priorPayload.ordinal !== input.ordinal + || priorPayload.trackId !== input.trackId) { + throw new Error('Vibe playback-error event does not match the served plan item'); + } + const advancedTo = priorPayload.advancedTo as { planVersionId?: unknown; ordinal?: unknown } | null | undefined; + if (!advancedTo || typeof advancedTo.planVersionId !== 'string' || !Number.isInteger(advancedTo.ordinal)) { + return { item: null, stale: false }; + } + const replacement = await client.query( + `SELECT * FROM vibe_plan_items WHERE plan_version_id = $1 AND ordinal = $2`, + [advancedTo.planVersionId, advancedTo.ordinal], + ); + return { item: (replacement.rows[0] as VibePlanItem | undefined) ?? null, stale: false }; + } + + if (sessionRow.status !== 'active') { + throw new Error(`Cannot record a new event for ${sessionRow.status} Vibe session`); + } + + // A client may advance only the cursor it was just served. Checking for + // any historical serve event would let an old version-aware /next + // response consume whichever future item happens to be uncommitted. + const current = await client.query( + `SELECT i.track_id, i.ordinal + FROM vibe_events e + JOIN vibe_plan_items i + ON i.plan_version_id = $3::uuid + AND i.ordinal = (e.payload->>'ordinal')::integer + AND i.track_id = e.track_id + WHERE e.session_id = $1 + AND e.type = 'track_served' + AND e.payload->>'planVersion' = $2::text + AND e.payload->>'planVersionId' = $3 + ORDER BY i.ordinal DESC + LIMIT 1`, + [sessionId, input.expectedPlanVersion, input.planVersionId], + ); + const currentCursor = current.rows[0] as Pick | undefined; + if (currentCursor?.track_id !== input.trackId || currentCursor.ordinal !== input.ordinal) { + throw new Error('Vibe plan item is not the current served cursor for this session revision'); + } + + const payload = { + kind: 'unplayable_plan_item', + planVersion: input.expectedPlanVersion, + planVersionId: input.planVersionId, + ordinal: input.ordinal, + trackId: input.trackId, + }; + const playbackError = await client.query( + `INSERT INTO vibe_events (client_event_id, session_id, user_id, track_id, type, occurred_at, payload) + VALUES ($1::uuid, $2, $3, $4::uuid, 'playback_error', NOW(), $5::jsonb) + RETURNING id`, + [input.eventId, sessionId, userId, input.trackId, JSON.stringify(payload)], + ); + const eventId = playbackError.rows[0]?.id as string | undefined; + if (!eventId) throw new Error('Vibe playback-error event could not be recorded'); + + const item = await client.query( + `WITH next_item AS ( + SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i + WHERE i.plan_version_id = $1 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE + ) + UPDATE vibe_plan_items i SET committed = true + FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal + RETURNING i.*`, + [plan.id], + ); + const replacement = item.rows[0] as VibePlanItem | undefined; + if (replacement) { + await client.query( + `INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload) + VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`, + [sessionId, userId, replacement.track_id, JSON.stringify({ + planVersion: plan.version, + planVersionId: replacement.plan_version_id, + ordinal: replacement.ordinal, + })], + ); + } + await client.query( + `UPDATE vibe_events SET payload = $2::jsonb WHERE id = $1`, + [eventId, JSON.stringify({ + ...payload, + advancedTo: replacement + ? { planVersionId: replacement.plan_version_id, ordinal: replacement.ordinal } + : null, + })], + ); + await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]); + return { item: replacement ?? null, stale: false }; + }); + } + /** Read a specific plan revision, or the latest revision for a session. */ async getVibePlan(sessionId: string, userId: string, version?: number): Promise { const res = await this.pgClient.query( diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index bd05625..a1f5e4f 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -47,6 +47,7 @@ function setup() { endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }), resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }), serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }), + advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }), persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }), getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null), } as unknown as DbService; @@ -179,6 +180,25 @@ describe('VibeSessionCoordinator', () => { expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } }); }); + it('advances past an unplayable served item using a distinct idempotent event protocol', async () => { + const { db, coordinator } = setup(); + const planVersionId = '44444444-4444-4444-8444-444444444444'; + const eventId = '55555555-5555-4555-8555-555555555555'; + + const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, { + expectedPlanVersion: 1, + planVersionId, + ordinal: 0, + trackId: TRACK_ID, + eventId, + }); + + expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', { + expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId, + }); + expect(result.now).toMatchObject({ track_id: TRACK_ID }); + }); + it('ends an active session once and preserves its latest persisted plan', async () => { const { db, coordinator } = setup(); const response = await coordinator.end('user-1', SESSION_ID); diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index 79efeaa..c52f846 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -36,6 +36,19 @@ export interface AppendVibeEventInput { payload?: Record; } +/** + * An explicit advancement protocol for a plan item that was durably served + * but cannot be played locally. `eventId` is the idempotency key for this + * state transition; it is intentionally separate from a retry of /next. + */ +export interface AdvanceUnplayableVibeItemInput { + expectedPlanVersion: number; + planVersionId: string; + ordinal: number; + trackId: string; + eventId: string; +} + export class VibeSessionNotFoundError extends Error {} export class VibeSessionLifecycleError extends Error {} export class VibePlanNotFoundError extends Error {} @@ -142,6 +155,21 @@ export class VibeSessionCoordinator { } } + async advancePastUnplayable( + userId: string, + sessionId: string, + input: AdvanceUnplayableVibeItemInput, + ): Promise { + try { + const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input); + const response = await this.getPlan(userId, sessionId); + if (served.stale) return response; + return { ...response, now: served.item, preview: response.preview }; + } catch (error) { + throw this.mapLifecycleError(error); + } + } + async appendEvent( userId: string, sessionId: string, diff --git a/docker-compose.yml b/docker-compose.yml index eb5ccf4..f9265a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,10 @@ services: TYPESENSE_API_KEY: ${TYPESENSE_API_KEY} MUZICK_API_KEY: ${MUZICK_API_KEY} MUZICK_ADMIN_KEY: ${MUZICK_ADMIN_KEY} + # Durable Vibe sessions are intentionally bound to this configured, + # server-trusted owner instead of accepting a client-supplied user id. + # Set it to the UUID of the local Muzick user in .env. + MUZICK_VIBE_USER_ID: ${MUZICK_VIBE_USER_ID} MUSIC_DIR: /music volumes: # READ-ONLY, deliberately. Nothing in the API request path may write to diff --git a/frontend/src/components/AudioEngine.test.tsx b/frontend/src/components/AudioEngine.test.tsx new file mode 100644 index 0000000..d37e2c4 --- /dev/null +++ b/frontend/src/components/AudioEngine.test.tsx @@ -0,0 +1,45 @@ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Track } from '../types'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { useVibeStore } from '../store/useVibeStore'; + +const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({ + advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined), + reportVibeEvent: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent })); + +import { AudioEngine } from './AudioEngine'; + +const track = (id: string): Track => ({ + id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album', + duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0, +}); + +describe('AudioEngine', () => { + beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + useVibeStore.getState().reset(); + useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' }); + usePlaybackStore.setState({ + currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false, + queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => { + const { container } = render(); + const audio = container.querySelector('audio')!; + + audio.dispatchEvent(new Event('error')); + audio.dispatchEvent(new Event('error')); + + await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song')); + expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1); + expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song'); + }); +}); diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx index 1d85399..e68ac84 100644 --- a/frontend/src/components/AudioEngine.tsx +++ b/frontend/src/components/AudioEngine.tsx @@ -2,17 +2,9 @@ import { useEffect, useRef } from 'react'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { useVibeStore } from '../store/useVibeStore'; import { trackService } from '../services/trackService'; -import { vibeService } from '../services/vibeService'; +import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession'; import type { Track } from '../types'; -// Track ids whose next natural feedback transition should be skipped because -// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for -// them explicitly. Consumed once, then cleared. -const suppressedFeedbackIds = new Set(); -export function suppressAutoFeedback(trackId: string): void { - suppressedFeedbackIds.add(trackId); -} - // Threshold (seconds) above which a store position change is treated as a user // scrub and applied to the audio element. Keeps the timeupdate -> setPosition -> // effect loop from fighting itself. @@ -48,6 +40,8 @@ export const AudioEngine = () => { const endedNaturallyRef = useRef(false); // Track whether the current track has crossed the completion threshold. const crossedThresholdRef = useRef(false); + const lastProgressSecondRef = useRef(-1); + const streamErrorTrackIdRef = useRef(null); // --- DOM -> store: media events ----------------------------------------- useEffect(() => { @@ -66,6 +60,13 @@ export const AudioEngine = () => { ) { crossedThresholdRef.current = true; } + const vibe = useVibeStore.getState(); + const track = store().currentTrack; + const elapsed = Math.floor(audio.currentTime); + if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) { + lastProgressSecondRef.current = elapsed; + void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined); + } }; const onLoadedMetadata = () => { if (Number.isFinite(audio.duration)) store().setDuration(audio.duration); @@ -83,7 +84,20 @@ export const AudioEngine = () => { // feedback, on the resulting track-change, so completion is recorded // exactly once per track. endedNaturallyRef.current = true; - store().next(); + store().nextWithReason('completed'); + }; + const onError = () => { + const playback = store(); + const track = playback.currentTrack; + const vibe = useVibeStore.getState(); + // Metadata can be available while the stream itself is no longer + // readable. Vibe must advance that exact durable cursor, not fall back + // to ordinary queue navigation or feedback-driven replanning. + if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return; + streamErrorTrackIdRef.current = track.id; + void advancePastUnplayableVibeTrack(track.id) + .catch(() => undefined) + .finally(() => { streamErrorTrackIdRef.current = null; }); }; audio.addEventListener('timeupdate', onTimeUpdate); @@ -91,6 +105,7 @@ export const AudioEngine = () => { audio.addEventListener('play', onPlay); audio.addEventListener('pause', onPause); audio.addEventListener('ended', onEnded); + audio.addEventListener('error', onError); return () => { audio.removeEventListener('timeupdate', onTimeUpdate); @@ -98,6 +113,7 @@ export const AudioEngine = () => { audio.removeEventListener('play', onPlay); audio.removeEventListener('pause', onPause); audio.removeEventListener('ended', onEnded); + audio.removeEventListener('error', onError); }; }, []); @@ -109,28 +125,19 @@ export const AudioEngine = () => { const applyTrack = (id: string | null) => { if (id === loadedIdRef.current) return; - // The previously loaded track is changing. If it didn't end naturally and - // hadn't crossed the completion threshold, record a skip (best-effort). - // If it crossed the threshold OR ended naturally, record as completed. + // The durable Vibe controller owns normal next/ended navigation. It + // records the outcome, receives a new plan revision, then calls the raw + // advance method. Do not emit a second event here after that transition. const prevId = loadedIdRef.current; const completed = endedNaturallyRef.current || crossedThresholdRef.current; - // Only vibe sessions want this feedback — plain library browsing - // shouldn't write skip/completed evidence for tracks merely sampled. - const inVibeSession = !!useVibeStore.getState().activeSessionId; - if (prevId && inVibeSession) { - if (suppressedFeedbackIds.delete(prevId)) { - // Caller already recorded explicit feedback (e.g. dislike) for - // this track — don't also record the implicit transition. - } else { - try { - void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {}); - } catch { - /* best-effort */ - } - } + const playback = usePlaybackStore.getState(); + const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe'; + if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) { + void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined); } endedNaturallyRef.current = false; crossedThresholdRef.current = false; + lastProgressSecondRef.current = -1; loadedIdRef.current = id; if (!id) { @@ -141,6 +148,7 @@ export const AudioEngine = () => { audio.src = trackService.getStreamUrl(id); audio.load(); + if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined); if (usePlaybackStore.getState().isPlaying) { void audio.play().catch(() => {}); } diff --git a/frontend/src/components/TrackRow.tsx b/frontend/src/components/TrackRow.tsx index 3ef7484..aa4a2f1 100644 --- a/frontend/src/components/TrackRow.tsx +++ b/frontend/src/components/TrackRow.tsx @@ -26,9 +26,11 @@ interface TrackRowProps { showVibe?: boolean; /** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */ onSelect?: (track: Track) => void; + /** Display-only rows keep their surrounding playback controller authoritative. */ + playable?: boolean; } -export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect }: TrackRowProps) { +export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) { const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); const dislikeTrack = useDislikeTrack(); const router = useRouter(); @@ -36,6 +38,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default' const compact = variant === 'compact'; const handlePlay = () => { + if (!playable) return; if (onSelect) { onSelect(track); return; @@ -47,7 +50,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default' playTrack(track); }; - const playLabel = isCurrent && isPlaying + const playLabel = !playable + ? `${track.title || 'Track'} is queued by Vibe` + : isCurrent && isPlaying ? `Pause ${track.title || 'track'}` : `Play ${track.title || 'track'}`; @@ -81,8 +86,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default' - - )} + {planVersion &&

Plan revision {planVersion}; upcoming tracks may change as you listen.

} {currentTrack && (
diff --git a/frontend/src/services/vibeService.test.ts b/frontend/src/services/vibeService.test.ts index 0f45bfa..6542ac2 100644 --- a/frontend/src/services/vibeService.test.ts +++ b/frontend/src/services/vibeService.test.ts @@ -1,32 +1,41 @@ -import { AxiosError } from 'axios'; import { describe, expect, it, vi } from 'vitest'; -import type { Track } from '../types'; -import { fetchNextBatch, vibeService } from './vibeService'; -const track = (id: string): Track => ({ - id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', - album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL', - play_count: 0, skip_count: 0, dislike_count: 0, -}); +const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() })); +vi.mock('./api', () => ({ default: { post, get } })); -function responseError(status: number, code?: string) { - return new AxiosError('request failed', undefined, undefined, undefined, { - data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never, - }); -} +import { vibeService } from './vibeService'; -describe('fetchNextBatch', () => { - it('uses the supplied session id and treats VIBE_PLAN_EXHAUSTED as terminal', async () => { - const next = vi.spyOn(vibeService, 'next') - .mockResolvedValueOnce({ track: track('one'), explanation: null, planRemaining: 0 }) - .mockRejectedValueOnce(responseError(409, 'VIBE_PLAN_EXHAUSTED')); +describe('durable vibe service', () => { + it('serves the next item with the caller plan version', async () => { + post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } }); - await expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' }); - expect(next).toHaveBeenCalledWith('session-a'); + await vibeService.next('session', 3); + + expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/next', { expectedPlanVersion: 3 }); }); - it('does not disguise a missing or replaced session as normal exhaustion', async () => { - vi.spyOn(vibeService, 'next').mockRejectedValue(responseError(404)); - await expect(fetchNextBatch(1, 'expired-session')).resolves.toEqual({ tracks: [], status: 'failed' }); + it('uses an explicit idempotency key to advance a served but unplayable item', async () => { + post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } }); + + await vibeService.advancePastUnplayable('session', 3, { + eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track', + }); + + expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/next', { + expectedPlanVersion: 3, + unplayable: { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track' }, + }); + }); + + it('sends client event ids to the durable event ledger', async () => { + post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 2, now: null, preview: [] } }); + + await vibeService.event('session', { + eventId: 'event', type: 'progress', occurredAt: '2026-01-01T00:00:00.000Z', trackId: 'track', positionMs: 30000, + }); + + expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/events', expect.objectContaining({ + eventId: 'event', type: 'progress', trackId: 'track', positionMs: 30000, + })); }); }); diff --git a/frontend/src/services/vibeService.ts b/frontend/src/services/vibeService.ts index 600e02e..7b22343 100644 --- a/frontend/src/services/vibeService.ts +++ b/frontend/src/services/vibeService.ts @@ -1,88 +1,101 @@ import api from './api'; -import axios from 'axios'; -import type { Track } from '../types'; -// A candidate from the v2 recommendation plan. The plan is stored server-side -// in Redis; the frontend only needs trackId + explanation for display. +// These types intentionally mirror the durable session API. Tracks are not +// embedded in a plan revision: the client resolves ids through the normal +// library endpoint so a deleted/hidden track can never become playable merely +// because an older plan mentioned it. export interface VibePlanItem { - trackId: string; - generatorId: string; - explanation: unknown[]; - relevance: number; + plan_version_id: string; + ordinal: number; + track_id: string; + slot_role: string | null; + candidate_source: string; + score: number; + score_breakdown: Record; + explanation: unknown; + committed: boolean; } -export interface VibeStartResponse { +export interface DurableVibeSessionResponse { sessionId: string; - plan: VibePlanItem[]; + planVersion: number | null; + now: VibePlanItem | null; + preview: VibePlanItem[]; + state: Record; + replanned: boolean; + replanReason: string | null; } -export interface VibeNextResponse { - track: Track; - explanation: unknown[] | null; - planRemaining: number; +export type VibeEventType = + | 'playback_started' + | 'progress' + | 'completed' + | 'skipped' + | 'disliked' + | 'kept'; + +export interface VibeEventInput { + eventId: string; + type: VibeEventType; + trackId?: string; + occurredAt: string; + positionMs?: number; + durationMs?: number; + payload?: Record; } -export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed'; - -export interface VibeBatchResult { - tracks: Track[]; - status: VibeBatchStatus; +export interface VibeEventResponse extends DurableVibeSessionResponse { + event: { id: string; client_event_id: string | null; type: string }; + idempotent: boolean; } -export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked'; +/** A durable, idempotent advancement past a plan item the player cannot load. */ +export interface VibeUnplayableItemInput { + eventId: string; + planVersionId: string; + ordinal: number; + trackId: string; +} -// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and -// serves tracks one at a time via GET /next. Feedback triggers replanning. export const vibeService = { - // POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan } - async start(seedTrackId?: string): Promise { - const res = await api.post('/v2/vibe/start', { seedTrackId }); + async start(seedTrackId?: string): Promise { + const res = await api.post('/v2/vibe/sessions', { seedTrackId }); return res.data; }, - // GET /api/v2/vibe/next -> { track, explanation, planRemaining } - // Returns one track at a time, shifting the server-side plan. - // 404 if no active plan — caller should handle gracefully. - async next(sessionId: string): Promise { - const res = await api.get('/v2/vibe/next', { params: { sessionId } }); + async getPlan(sessionId: string, version?: number): Promise { + const res = await api.get(`/v2/vibe/sessions/${sessionId}/plans`, { + params: version === undefined ? undefined : { version }, + }); return res.data; }, - // POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining } - // Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack. - // Triggers replan of the remaining plan. - async feedback(trackId: string, action: VibeFeedbackAction, sessionId?: string): Promise<{ status: string; planRemaining: number }> { - const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId }); + async next(sessionId: string, expectedPlanVersion: number): Promise { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/next`, { + expectedPlanVersion, + }); return res.data; }, - // GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan } - // Debug endpoint — returns the full remaining plan. - async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> { - const res = await api.get('/v2/vibe/plan'); + async advancePastUnplayable( + sessionId: string, + expectedPlanVersion: number, + unplayable: VibeUnplayableItemInput, + ): Promise { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/next`, { + expectedPlanVersion, + unplayable, + }); + return res.data; + }, + + async event(sessionId: string, event: VibeEventInput): Promise { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/events`, event); + return res.data; + }, + + async end(sessionId: string): Promise { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/end`); return res.data; }, }; - -// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the -// server-side plan, so calls must be sequential (not parallel). Stops early on -// 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced -// session is intentionally reported as a failure so callers can preserve the -// current playback state rather than pretending the plan completed cleanly. -export async function fetchNextBatch(count: number, sessionId: string): Promise { - const tracks: Track[] = []; - for (let i = 0; i < count; i++) { - try { - const { track } = await vibeService.next(sessionId); - tracks.push(track); - } catch (error) { - return { - tracks, - status: axios.isAxiosError(error) && error.response?.status === 409 && - (error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED' - ? 'exhausted' : 'failed', - }; - } - } - return { tracks, status: 'complete' }; -} diff --git a/frontend/src/services/vibeSession.test.ts b/frontend/src/services/vibeSession.test.ts index f2895a4..6a610ea 100644 --- a/frontend/src/services/vibeSession.test.ts +++ b/frontend/src/services/vibeSession.test.ts @@ -1,21 +1,16 @@ +import { AxiosError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Track } from '../types'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { useVibeStore } from '../store/useVibeStore'; -const { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() })); -vi.mock('./vibeService', () => ({ - vibeService: { start, next }, - fetchNextBatch: async (count: number, sessionId: string) => { - const tracks: Track[] = []; - for (let index = 0; index < count; index++) { - try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; } - } - return { tracks, status: 'complete' as const }; - }, +const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({ + start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(), })); +vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } })); +vi.mock('./trackService', () => ({ trackService: { getTrack } })); -import { startVibeSession } from './vibeSession'; +import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession'; const track = (id: string): Track => ({ id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', @@ -23,34 +18,237 @@ const track = (id: string): Track => ({ play_count: 0, skip_count: 0, dislike_count: 0, }); -describe('startVibeSession', () => { +const item = (track_id: string, committed = false, ordinal = 0) => ({ + plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test', + score: 1, score_breakdown: {}, explanation: [], committed, +}); + +const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({ + sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null, +}); + +describe('durable Vibe session client', () => { beforeEach(() => { vi.clearAllMocks(); useVibeStore.getState().reset(); - usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false }); + usePlaybackStore.setState({ + currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary', + }); + getTrack.mockImplementation((id: string) => Promise.resolve(track(id))); }); - it('does not replace a working Vibe when the new plan cannot hydrate', async () => { - const old = track('old'); - useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id }); - usePlaybackStore.getState().setQueue([old]); - usePlaybackStore.getState().playTrack(old); - start.mockResolvedValue({ sessionId: 'new-session', plan: [] }); - next.mockRejectedValue(new Error('missing session')); + it('starts by version-serving and hydrating the first durable plan item', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next.mockResolvedValue(response(1, item('one', true), [item('two')])); - await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' }); - expect(useVibeStore.getState().activeSessionId).toBe('old-session'); - expect(usePlaybackStore.getState().currentTrack?.id).toBe('old'); + await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] }); + + expect(next).toHaveBeenCalledWith('session-a', 1); + expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] }); + expect(usePlaybackStore.getState().currentTrack).toEqual(track('one')); }); - it('serializes rapid starts and hydrates only one session', async () => { - const recommended = track('recommended'); - start.mockResolvedValue({ sessionId: 'session-a', plan: [] }); - next.mockResolvedValue({ track: recommended }); + it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => { + start.mockResolvedValue(response(1, item('one'), [item('stale')])); + next + .mockResolvedValueOnce(response(1, item('one', true), [item('stale')])) + .mockResolvedValueOnce(response(2, item('two', true), [item('three')])); + event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false }); + await startVibeSession(track('seed')); - await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]); - expect(start).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledWith('session-a'); - expect(useVibeStore.getState().activeSessionId).toBe('session-a'); + await advanceVibe('skipped'); + + expect(next).toHaveBeenLastCalledWith('session-a', 2); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); + expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale'); + expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) })); + }); + + it('replaces only the future when a keep event replans', async () => { + start.mockResolvedValue(response(1, item('one'), [item('stale')])); + next.mockResolvedValue(response(1, item('one', true), [item('stale')])); + event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false }); + await startVibeSession(track('seed')); + + await reportVibeEvent('kept', 'one'); + + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('one'); + expect(useVibeStore.getState().planVersion).toBe(2); + }); + + it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => { + start.mockResolvedValue(response(1, item('one'), [item('stale')])); + next.mockResolvedValue(response(1, item('one', true), [item('stale')])); + // The first response was lost after it published revision 2. Retrying the + // same client event returns that revision with replanned=false. + event.mockResolvedValue({ + ...response(2, item('fresh'), [item('fresh'), item('later')]), + replanned: false, + event: {}, + idempotent: true, + }); + await startVibeSession(track('seed')); + + await reportVibeEvent('kept', 'one'); + + expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] }); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']); + }); + + it('cleans up local playback when the durable session is gone', async () => { + start.mockResolvedValue(response(1, item('one'), [item('stale')])); + next.mockResolvedValue(response(1, item('one', true), [item('stale')])); + event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, { + data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never, + })); + await startVibeSession(track('seed')); + + await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone'); + + expect(useVibeStore.getState().activeSessionId).toBeNull(); + expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false }); + }); + + it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next.mockResolvedValue(response(1, item('one', true), [item('two')])); + await startVibeSession(track('seed')); + + const ordinary = track('ordinary'); + const playback = usePlaybackStore.getState(); + playback.setQueue([ordinary, track('ordinary-next')]); + playback.playTrack(ordinary); + playback.nextWithReason('completed'); + + expect(usePlaybackStore.getState()).toMatchObject({ + queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null, + }); + expect(event).not.toHaveBeenCalled(); + }); + + it('serializes material events and ignores an older plan revision', async () => { + start.mockResolvedValue(response(1, item('one'), [item('old')])); + next.mockResolvedValue(response(1, item('one', true), [item('old')])); + await startVibeSession(track('seed')); + + let resolveFirst!: (value: ReturnType & { event: object; idempotent: boolean }) => void; + event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })); + event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false }); + + const first = reportVibeEvent('kept', 'one'); + const second = reportVibeEvent('completed', 'one'); + await Promise.resolve(); + expect(event).toHaveBeenCalledTimes(1); + resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false }); + await Promise.all([first, second]); + + expect(event).toHaveBeenCalledTimes(2); + expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] }); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']); + }); + + it('retries a failed event with the same idempotency key until it is acknowledged', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next.mockResolvedValue(response(1, item('one', true), [item('two')])); + event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({ + ...response(1), event: {}, idempotent: true, + }); + await startVibeSession(track('seed')); + + await reportVibeEvent('progress', 'one', 30000, 180000); + + expect(event).toHaveBeenCalledTimes(2); + expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId); + }); + + it('skips a hidden plan item and starts from the next playable item', async () => { + start.mockResolvedValue(response(1, item('hidden'), [item('good')])); + next + .mockResolvedValueOnce(response(1, item('hidden', true), [item('good')])); + advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')])); + getTrack.mockImplementation((id: string) => id === 'hidden' + ? Promise.resolve({ ...track(id), state: 'HIDDEN' }) + : Promise.resolve(track(id))); + + await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] }); + + expect(next).toHaveBeenCalledTimes(1); + expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({ + planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String), + })); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('good'); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden'); + }); + + it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => { + start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)])); + next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)])); + advancePastUnplayable + .mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)])) + .mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)])); + getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id) + ? Promise.resolve({ ...track(id), state: 'HIDDEN' }) + : Promise.resolve(track(id))); + + await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ + status: 'complete', tracks: [track('good'), track('later')], + }); + + expect(next).toHaveBeenCalledTimes(1); + expect(advancePastUnplayable).toHaveBeenCalledTimes(2); + expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId])) + .toEqual([[0, 'hidden-one'], [1, 'hidden-two']]); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('good'); + }); + + it('retries an unplayable advancement with its original event id after a lost response', async () => { + start.mockResolvedValue(response(1, item('hidden'), [item('good')])); + next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')])); + advancePastUnplayable + .mockRejectedValueOnce(new Error('response dropped')) + .mockResolvedValueOnce(response(1, item('good', true), [item('later')])); + getTrack.mockImplementation((id: string) => id === 'hidden' + ? Promise.resolve({ ...track(id), state: 'MISSING' }) + : Promise.resolve(track(id))); + + await startVibeSession(track('seed')); + + expect(advancePastUnplayable).toHaveBeenCalledTimes(2); + expect(advancePastUnplayable.mock.calls[0][2].eventId) + .toBe(advancePastUnplayable.mock.calls[1][2].eventId); + }); + + it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)])); + next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)])); + advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)])); + await startVibeSession(track('seed')); + + await advancePastUnplayableVibeTrack('one'); + + expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({ + planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String), + })); + expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' })); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); + expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 }); + }); + + it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next.mockResolvedValue(response(1, item('one', true), [item('two')])); + end.mockResolvedValue(response(1)); + await startVibeSession(track('seed')); + + await endVibeSession(); + + expect(end).toHaveBeenCalledWith('session-a'); + expect(useVibeStore.getState().activeSessionId).toBeNull(); + expect(usePlaybackStore.getState()).toMatchObject({ + queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false, + }); }); }); diff --git a/frontend/src/services/vibeSession.ts b/frontend/src/services/vibeSession.ts index 30128a6..b6332da 100644 --- a/frontend/src/services/vibeSession.ts +++ b/frontend/src/services/vibeSession.ts @@ -1,25 +1,417 @@ +import axios from 'axios'; import type { Track } from '../types'; -import { usePlaybackStore } from '../store/usePlaybackStore'; +import { usePlaybackStore, type VibeAdvanceReason } from '../store/usePlaybackStore'; import { useVibeStore } from '../store/useVibeStore'; -import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService'; - -export const INITIAL_VIBE_BATCH_SIZE = 5; +import { + vibeService, + type DurableVibeSessionResponse, + type VibeEventType, + type VibePlanItem, +} from './vibeService'; +import { trackService } from './trackService'; export interface StartedVibeSession { - status: VibeBatchStatus; + status: 'complete' | 'exhausted' | 'failed'; tracks: Track[]; } let startInFlight: Promise | null = null; +let advanceInFlight: Promise | null = null; +let materialTail: Promise = Promise.resolve(); + +interface PendingEvent { + sessionId: string; + input: Parameters[1]; + retried: boolean; + settled: boolean; + resolve: (response: DurableVibeSessionResponse) => void; + reject: (error: unknown) => void; +} + +// The event ledger deduplicates client_event_id. Keep an event in this ordered +// outbox until the server acknowledges it so a transient failure never turns a +// retry into a second listener action. +const eventOutbox: PendingEvent[] = []; +let flushingOutbox = false; + +function serializeMaterial(operation: () => Promise): Promise { + const result = materialTail.then(operation, operation); + materialTail = result.then(() => undefined, () => undefined); + return result; +} + +function newEventId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + // UUID v4-shaped fallback for older embedded webviews. The server only uses + // this as an idempotency key, not as a source of entropy. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (letter) => { + const value = Math.floor(Math.random() * 16); + return (letter === 'x' ? value : (value & 0x3) | 0x8).toString(16); + }); +} + +function isPlayable(track: Track): boolean { + return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state); +} + +async function hydrateItem(item: VibePlanItem | null): Promise { + if (!item) return null; + try { + const track = await trackService.getTrack(item.track_id); + return isPlayable(track) ? track : null; + } catch { + // A plan can outlive a hidden/deleted file. Never substitute another item + // for this ordinal: keeping the remaining order is safer than a mismatch. + return null; + } +} + +async function hydratePreview(items: VibePlanItem[]): Promise { + const uniqueIds = [...new Set(items.map((item) => item.track_id))]; + const loaded = await Promise.all(uniqueIds.map(async (id) => { + try { + const track = await trackService.getTrack(id); + return [id, isPlayable(track) ? track : null] as const; + } catch { + return [id, null] as const; + } + })); + const byId = new Map(loaded.filter((entry): entry is readonly [string, Track] => entry[1] !== null)); + return items.flatMap((item) => { + const track = byId.get(item.track_id); + return track ? [track] : []; + }); +} + +/** Replace only the queue after the currently playing Vibe track. */ +function replaceUnplayedQueue(preview: Track[]): void { + const playback = usePlaybackStore.getState(); + const current = playback.currentTrack; + const queueIndex = current + ? (playback.currentIndex >= 0 && playback.queue[playback.currentIndex]?.id === current.id + ? playback.currentIndex + : playback.queue.findIndex((track) => track.id === current.id)) + : -1; + const history = queueIndex >= 0 + ? playback.queue.slice(0, queueIndex + 1) + : current ? [current] : []; + const seen = new Set(history.map((track) => track.id)); + const future = preview.filter((track) => !seen.has(track.id)); + playback.setVibeQueue([...history, ...future]); +} + +function isCurrentVibeOwner(sessionId: string): boolean { + return useVibeStore.getState().activeSessionId === sessionId + && usePlaybackStore.getState().queueOwner === 'vibe'; +} + +async function reconcilePreview(sessionId: string, response: DurableVibeSessionResponse): Promise { + if (!isCurrentVibeOwner(sessionId)) return []; + const preview = await hydratePreview(response.preview); + if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return []; + replaceUnplayedQueue(preview); + return preview; +} + +async function serveNextCurrent(sessionId: string, version: number): Promise { + // A concurrent device or a feedback replan can make a version stale between + // the event response and /next. A stale response has an uncommitted `now`; + // refresh once with its latest version before admitting a track to playback. + let response = await vibeService.next(sessionId, version); + if (response.now?.committed) return response; + if (!response.planVersion || response.planVersion === version) return response; + response = await vibeService.next(sessionId, response.planVersion); + return response; +} + +async function serveNextPlayable( + sessionId: string, + version: number, +): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> { + return resolvePlayableResponse(sessionId, await serveNextCurrent(sessionId, version)); +} + +async function advanceResponsePastUnplayable( + sessionId: string, + response: DurableVibeSessionResponse, +): Promise { + if (!response.now?.committed || !response.planVersion) return response; + const unplayable = { + eventId: newEventId(), + planVersionId: response.now.plan_version_id, + ordinal: response.now.ordinal, + trackId: response.now.track_id, + }; + try { + return await vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable); + } catch (error) { + // A response may have been lost after the server committed the advance. + // Retry the same event id so it returns the same replacement rather than + // consuming another future item. + if (isSessionTerminalError(error)) throw error; + return vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable); + } +} + +async function resolvePlayableResponse( + sessionId: string, + initialResponse: DurableVibeSessionResponse, +): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> { + let response = initialResponse; + // A durable plan can reference a file which has since become hidden. Commit + // past such entries but never load one into the player. An unplayable + // advancement already returns and commits its replacement, so process that + // response directly: asking ordinary /next again would replay the original + // served cursor rather than advancing through consecutive hidden entries. + for (let attempts = 0; attempts < 20; attempts++) { + if (!response.now?.committed || !response.planVersion) { + if (!response.planVersion) return null; + response = await serveNextCurrent(sessionId, response.planVersion); + continue; + } + let now = await hydrateItem(response.now); + let preview = await hydratePreview(response.preview); + if (now) return { response, now, preview }; + const advanced = await advanceResponsePastUnplayable(sessionId, response); + if (!advanced.planVersion) return null; + // The unplayable transition may itself race a feedback replan. Its stale + // response did not advance the old revision, so version-serve the current + // revision normally rather than treating a preview item as committed. + if (!advanced.now?.committed) { + response = await serveNextCurrent(sessionId, advanced.planVersion); + continue; + } + response = advanced; + } + return null; +} + +function deactivateBrokenSession(): void { + const playback = usePlaybackStore.getState(); + playback.setVibeAdvanceHandler(null); + useVibeStore.getState().reset(); + playback.pause(); + playback.setQueue([]); + playback.setCurrentTrack(null); +} + +function isSessionTerminalError(error: unknown): boolean { + return axios.isAxiosError(error) && [401, 404, 409].includes(error.response?.status ?? 0); +} + +export function vibeErrorMessage(error: unknown): string { + if (!axios.isAxiosError(error)) return 'Could not refresh this Vibe. Please try again.'; + switch (error.response?.status) { + case 401: return 'Vibe needs a trusted local user identity. Set MUZICK_VIBE_USER_ID and try again.'; + case 404: return 'This Vibe session is no longer available.'; + case 409: return 'This Vibe session has already ended or was replaced.'; + default: return 'Could not refresh this Vibe. Please try again.'; + } +} + +async function sendEvent( + type: VibeEventType, + trackId?: string, + positionMs?: number, + durationMs?: number, +): Promise { + const sessionId = useVibeStore.getState().activeSessionId; + if (!sessionId) return null; + const input = { + eventId: newEventId(), + type, + trackId, + occurredAt: new Date().toISOString(), + positionMs, + durationMs, + }; + return new Promise((resolve, reject) => { + eventOutbox.push({ sessionId, input, retried: false, settled: false, resolve, reject }); + void flushEventOutbox(); + }); +} + +function retryableEventError(error: unknown): boolean { + return !isSessionTerminalError(error); +} + +async function flushEventOutbox(): Promise { + if (flushingOutbox) return; + flushingOutbox = true; + try { + while (eventOutbox.length > 0) { + const entry = eventOutbox[0]; + try { + const response = await vibeService.event(entry.sessionId, entry.input); + eventOutbox.shift(); + entry.settled = true; + entry.resolve(response); + } catch (error) { + // Retry once immediately using the exact same client event id. After + // that leave it at the head for a later retry, rather than discarding + // the idempotency key or allowing newer material events to overtake it. + if (!entry.retried && retryableEventError(error)) { + entry.retried = true; + continue; + } + // A session that is gone/ended can never acknowledge this event. Do + // not let an irrecoverable old-session entry block a later session. + if (isSessionTerminalError(error)) eventOutbox.shift(); + if (!entry.settled) { + entry.settled = true; + entry.reject(error); + } + return; + } + } + } finally { + flushingOutbox = false; + } +} /** - * Start a V2 plan and immediately hand its first recommendations to playback. - * Keeping this in one place prevents entry points from accidentally replacing a - * generated Vibe queue with a normal browse queue. + * Send a non-navigation event. Material feedback reconciles the future before + * it resolves, so no old prefetch remains after Keep or an implicit update. */ +export async function reportVibeEvent( + type: VibeEventType, + trackId?: string, + positionMs?: number, + durationMs?: number, +): Promise { + return serializeMaterial(async () => { + const sessionId = useVibeStore.getState().activeSessionId; + if (!sessionId || !isCurrentVibeOwner(sessionId)) return; + try { + const response = await sendEvent(type, trackId, positionMs, durationMs); + // Even a duplicate material event can acknowledge a canonical + // replacement revision (replanned=false). Reconcile every valid + // revision so a response lost after its original replan cannot leave a + // stale locally-prefetched future behind. + if (response?.planVersion !== null && response?.planVersion !== undefined) { + await reconcilePreview(sessionId, response); + } + } catch (error) { + if (isSessionTerminalError(error)) deactivateBrokenSession(); + throw error; + } + }); +} + +/** Advance only after the prior track's durable outcome has produced a new plan. */ +export function advanceVibe(reason: VibeAdvanceReason): Promise { + if (advanceInFlight) return advanceInFlight; + advanceInFlight = serializeMaterial(async () => { + const vibe = useVibeStore.getState(); + const current = usePlaybackStore.getState().currentTrack; + if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return; + + try { + const feedback = await sendEvent(reason, current.id); + if (!feedback?.planVersion) { + usePlaybackStore.getState().pause(); + return; + } + const served = await serveNextPlayable(vibe.activeSessionId, feedback.planVersion); + if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) { + // Never play an uncommitted or unresolvable plan item. The user can + // retry from the page after the director publishes another revision. + replaceUnplayedQueue([]); + usePlaybackStore.getState().pause(); + return; + } + if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return; + useVibeStore.getState().setCurrentPlanItem(served.response.now); + replaceUnplayedQueue([served.now, ...served.preview]); + usePlaybackStore.getState().advance(); + } catch (error) { + // Clearing the future is deliberate: carrying on with stale prefetches + // after a rejected feedback/replan would violate the plan boundary. + replaceUnplayedQueue([]); + if (isSessionTerminalError(error)) deactivateBrokenSession(); + else usePlaybackStore.getState().pause(); + throw error; + } + }).finally(() => { advanceInFlight = null; }); + return advanceInFlight; +} + +/** + * A stream can fail after its track metadata was successfully hydrated. This + * advances the exact durable cursor through the explicit unplayable protocol, + * rather than treating it as ordinary feedback and allowing a replan to hide + * the failure. + */ +export function advancePastUnplayableVibeTrack(trackId: string): Promise { + if (advanceInFlight) return advanceInFlight; + advanceInFlight = serializeMaterial(async () => { + const vibe = useVibeStore.getState(); + const playback = usePlaybackStore.getState(); + const currentItem = vibe.currentPlanItem; + if (!vibe.activeSessionId || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) return; + + try { + const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, { + sessionId: vibe.activeSessionId, + planVersion: vibe.planVersion, + now: currentItem, + preview: [], + state: {}, + replanned: false, + replanReason: null, + }); + const served = await resolvePlayableResponse(vibe.activeSessionId, advanced); + if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) { + replaceUnplayedQueue([]); + playback.pause(); + return; + } + if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return; + useVibeStore.getState().setCurrentPlanItem(served.response.now); + replaceUnplayedQueue([served.now, ...served.preview]); + playback.advance(); + } catch (error) { + replaceUnplayedQueue([]); + if (isSessionTerminalError(error)) deactivateBrokenSession(); + else playback.pause(); + throw error; + } + }).finally(() => { advanceInFlight = null; }); + return advanceInFlight; +} + +function installVibeAdvanceHandler(): void { + usePlaybackStore.getState().setVibeAdvanceHandler((reason) => { + void advanceVibe(reason).catch(() => undefined); + }); +} + +/** Start, version-serve and hydrate the first durable Vibe track. */ export async function startVibeSession(seed: Track): Promise { if (startInFlight) return startInFlight; - startInFlight = beginVibeSession(seed); + startInFlight = serializeMaterial(async () => { + const started = await vibeService.start(seed.id); + if (!started.planVersion) return { status: 'exhausted', tracks: [] }; + const served = await serveNextPlayable(started.sessionId, started.planVersion); + if (!served) return { status: 'exhausted', tracks: [] }; + + const vibe = useVibeStore.getState(); + // A newly started session has its own revision sequence. Drop the old + // local revision before admitting revision 1 from this new session. + vibe.reset(); + vibe.setInitialBatchStatus('loading'); + vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id }); + vibe.setCenterTrack(seed); + vibe.setPlan(served.response.planVersion, served.preview); + vibe.setCurrentPlanItem(served.response.now); + vibe.setInitialBatchStatus('idle'); + + const playback = usePlaybackStore.getState(); + playback.setVibeQueue([served.now, ...served.preview]); + playback.playTrack(served.now); + installVibeAdvanceHandler(); + return { status: 'complete', tracks: [served.now, ...served.preview] }; + }); try { return await startInFlight; } finally { @@ -27,25 +419,18 @@ export async function startVibeSession(seed: Track): Promise } } -async function beginVibeSession(seed: Track): Promise { - const { sessionId } = await vibeService.start(seed.id); - const vibe = useVibeStore.getState(); - // Do not replace a working Vibe until the new session has produced a usable - // initial batch. This also keeps the page prefetcher attached to the old - // session while this request is in flight. - const result = await fetchNextBatch(INITIAL_VIBE_BATCH_SIZE, sessionId); - if (result.tracks.length === 0) return result; - - vibe.setInitialBatchStatus('loading'); - vibe.setActiveSession({ sessionId, seedTrackId: seed.id }); - vibe.setSeedTrackId(seed.id); - vibe.setCenterTrack(seed); - vibe.setBuffer(result.tracks); - vibe.setInitialBatchStatus('idle'); - - const playback = usePlaybackStore.getState(); - playback.setQueue(result.tracks); - playback.playTrack(result.tracks[0]); - - return result; +export async function endVibeSession(): Promise { + return serializeMaterial(async () => { + const sessionId = useVibeStore.getState().activeSessionId; + try { + if (sessionId) await vibeService.end(sessionId); + } finally { + const playback = usePlaybackStore.getState(); + playback.setVibeAdvanceHandler(null); + useVibeStore.getState().reset(); + playback.pause(); + playback.setQueue([]); + playback.setCurrentTrack(null); + } + }); } diff --git a/frontend/src/store/usePlaybackStore.ts b/frontend/src/store/usePlaybackStore.ts index 01615e8..ce75c32 100644 --- a/frontend/src/store/usePlaybackStore.ts +++ b/frontend/src/store/usePlaybackStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { Track } from '../types'; export type RepeatMode = 'none' | 'all' | 'one'; +export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked'; +export type PlaybackOwner = 'ordinary' | 'vibe'; /** * How many already-played tracks to keep behind the cursor. Bounds queue growth @@ -22,12 +24,22 @@ interface PlaybackState { repeat: RepeatMode; /** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */ shufflePlayed: Set; + /** Installed only while a durable Vibe session owns the queue. */ + vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null; + /** Vibe must opt in explicitly; ordinary browsing always owns itself. */ + queueOwner: PlaybackOwner; setQueue: (queue: Track[]) => void; + /** Vibe-only queue replacement. Do not use for library browsing. */ + setVibeQueue: (queue: Track[]) => void; playTrack: (track: Track) => void; play: () => void; pause: () => void; next: () => void; + nextWithReason: (reason: VibeAdvanceReason) => void; + /** Bypass the Vibe controller after it has prepared the next committed track. */ + advance: () => void; + setVibeAdvanceHandler: (handler: ((reason: VibeAdvanceReason) => void) | null) => void; prev: () => void; setPosition: (position: number) => void; setDuration: (duration: number) => void; @@ -71,6 +83,8 @@ export const usePlaybackStore = create((set, get) => ({ shuffle: false, repeat: 'none', shufflePlayed: new Set(), + vibeAdvanceHandler: null, + queueOwner: 'ordinary', setQueue: (queue) => set((state) => ({ @@ -78,6 +92,18 @@ export const usePlaybackStore = create((set, get) => ({ // Keep the cursor pointing at whatever is playing, if it is still queued. currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1, shufflePlayed: new Set(), + // Every ordinary queue operation is an explicit ownership handoff. This + // prevents a stale Vibe session from intercepting browser/UI next. + queueOwner: 'ordinary', + vibeAdvanceHandler: null, + })), + + setVibeQueue: (queue) => + set((state) => ({ + queue, + currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1, + shufflePlayed: new Set(), + queueOwner: 'vibe', })), playTrack: (track) => @@ -94,6 +120,19 @@ export const usePlaybackStore = create((set, get) => ({ pause: () => set({ isPlaying: false }), next: () => { + get().nextWithReason('skipped'); + }, + + nextWithReason: (reason) => { + const { vibeAdvanceHandler: handler, queueOwner } = get(); + if (queueOwner === 'vibe' && handler) { + handler(reason); + return; + } + get().advance(); + }, + + advance: () => { const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get(); if (queue.length === 0) { set({ isPlaying: false, position: 0 }); @@ -169,6 +208,11 @@ export const usePlaybackStore = create((set, get) => ({ } }, + setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({ + vibeAdvanceHandler, + queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner, + })), + prev: () => { const { queue, currentTrack, currentIndex } = get(); if (queue.length === 0) return; diff --git a/frontend/src/store/useVibeStore.ts b/frontend/src/store/useVibeStore.ts index fd2272d..e079da3 100644 --- a/frontend/src/store/useVibeStore.ts +++ b/frontend/src/store/useVibeStore.ts @@ -1,30 +1,34 @@ import { create } from 'zustand'; import type { Track, VibeSession } from '../types'; +import type { VibePlanItem } from '../services/vibeService'; -// V2 recommendation session state. The backend stores the plan in Redis -// (keyed by sessionId) and serves tracks one at a time via GET /v2/vibe/next. -// We keep a lookahead buffer of upcoming Track[] to feed playback. +// The durable plan is authoritative. `buffer` is only its currently +// uncommitted, hydrated preview; it may be replaced at any feedback boundary. interface VibeState { activeSessionId: string | null; seedTrackId: string | null; + planVersion: number | null; + /** Durable cursor for the track currently in Vibe playback. */ + currentPlanItem: VibePlanItem | null; centerTrack: Track | null; - buffer: Track[]; // lookahead buffer of upcoming recommended tracks - /** Outcome of the first V2 batch, including sessions initiated from Discover. */ + buffer: Track[]; initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed'; setActiveSession: (session: VibeSession | null) => void; setSeedTrackId: (seedTrackId: string | null) => void; setCenterTrack: (track: Track | null) => void; - setBuffer: (buffer: Track[]) => void; + /** Returns false when a response belongs to an older plan revision. */ + setPlan: (planVersion: number | null, preview: Track[]) => boolean; + setCurrentPlanItem: (item: VibePlanItem | null) => void; setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void; - appendBuffer: (tracks: Track[]) => void; - shiftBuffer: () => Track | undefined; reset: () => void; } const initialState = { activeSessionId: null as string | null, seedTrackId: null as string | null, + planVersion: null as number | null, + currentPlanItem: null as VibePlanItem | null, centerTrack: null as Track | null, buffer: [] as Track[], initialBatchStatus: 'idle' as const, @@ -33,26 +37,20 @@ const initialState = { export const useVibeStore = create((set, get) => ({ ...initialState, - setActiveSession: (session) => - set( - session - ? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId } - : { activeSessionId: null, seedTrackId: null } - ), - + setActiveSession: (session) => set( + session + ? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId } + : { activeSessionId: null, seedTrackId: null }, + ), setSeedTrackId: (seedTrackId) => set({ seedTrackId }), setCenterTrack: (centerTrack) => set({ centerTrack }), - setBuffer: (buffer) => set({ buffer }), - setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }), - appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })), - - shiftBuffer: () => { - const { buffer } = get(); - if (buffer.length === 0) return undefined; - const [head, ...rest] = buffer; - set({ buffer: rest }); - return head; + setPlan: (planVersion, buffer) => { + const current = get().planVersion; + if (planVersion === null || (current !== null && planVersion < current)) return false; + set({ planVersion, buffer }); + return true; }, - + setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }), + setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }), reset: () => set({ ...initialState }), })); From 89a23e37032f2d576dff74b679995f1064d28ee9 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 2 Aug 2026 00:14:39 +0400 Subject: [PATCH 5/8] feat(vibe): enforce session diversity constraints --- .../src/services/session-director.service.ts | 815 +++++++++++++----- backend/src/services/session-director.test.ts | 340 +++++++- 2 files changed, 943 insertions(+), 212 deletions(-) diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 296c5e0..4e07647 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -4,6 +4,7 @@ import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js'; export interface FatigueState { artist: Map; + album: Map; genre: Map; language: Map; track: Map; @@ -17,9 +18,42 @@ export interface RecentPlay { bpm: number | null; energy: number | null; language: string | null; - vocal: boolean; + /** null means audio analysis is unavailable; it is not a vocal track. */ + vocal: boolean | null; decade: number | null; valence: number | null; + albumId?: string | null; + producerIds?: string[]; + labelIds?: string[]; +} + +/** Metadata used only by the planner. A missing value is deliberately not + * counted as satisfying a diversity target. */ +export interface CandidateConstraintMetadata { + artistId?: string; + albumId?: string; + genreId?: string; + language?: string; + instrumental?: boolean; + favorite?: boolean; + newArtist?: boolean; + energy?: number; + bpm?: number; + valence?: number; + decade?: number; + producerIds?: string[]; + labelIds?: string[]; +} + +export interface ConstraintRelaxation { + constraint: string; + stage: 'soft_budget' | 'arc_precision' | 'freshness'; + reason: string; +} + +export interface ConstrainedPlanResult { + plan: Candidate[]; + relaxations: ConstraintRelaxation[]; } export interface DiversityBudget { @@ -27,6 +61,10 @@ export interface DiversityBudget { budgetShare: number; horizonMin: number; spent: number; + /** Exact completed-play counts in this budget's configured time horizon. + * These are planner-only projections, not API contract fields. */ + historicalTotal?: number; + historicalValues?: Map; } export interface RepetitionState { @@ -34,9 +72,19 @@ export interface RepetitionState { recentArtistIds: Set; } +export interface AntiLoopSignal { + dimension: string; + /** Every dominant identity. Producer/label candidates are excluded when + * they match any of these values, not merely the first claim. */ + values: string[]; +} + export interface PlanBuildOptions { /** Tracks already exposed during this Vibe session; they are ineligible. */ excludedTrackIds?: Iterable; + /** Unplayed queue tail retained during a replan. It is part of the same + * sequence and must consume hard caps and diversity budgets. */ + retainedPlan?: Candidate[]; } const W_ENJOY = 1.0; @@ -45,6 +93,29 @@ const W_DIVERSITY = 0.3; const W_ENTROPY = 0.2; const W_REPETITION = 0.5; const PLAN_SIZE = 20; +const MAX_ARTIST_PER_PLAN = 2; +const MAX_ALBUM_PER_40_TRACKS = 3; +const ALBUM_HORIZON_TRACKS = 40; + +/** + * Producer/label relationships live on artist nodes in the fused graph, not + * on track nodes. The graph represents both directions of a relationship, + * so a track inherits every adjacent lineage identity from its resolved main + * artist regardless of which artist was recorded as the edge subject. + */ +function lineageIdsSql(predicate: 'produced' | 'same_label_as', artistAlias: string): string { + return `COALESCE(( + SELECT array_agg(DISTINCT CASE + WHEN cf.subject_id = ${artistAlias}.artist_id THEN cf.object_id::text + ELSE cf.subject_id::text + END) + FROM claim_fusion cf + WHERE cf.subject_type = 'artist' + AND cf.object_type = 'artist' + AND cf.predicate = '${predicate}' + AND (cf.subject_id = ${artistAlias}.artist_id OR cf.object_id = ${artistAlias}.artist_id) + ), ARRAY[]::text[])`; +} /** * Preserve the existing queue, append only genuinely new candidates, and @@ -70,7 +141,248 @@ export function mergeUniquePlan( return merged; } +function valuesForDimension( + metadata: CandidateConstraintMetadata | undefined, + dimension: string, +): string[] { + if (!metadata) return []; + switch (dimension) { + case 'artist': return metadata.artistId ? [metadata.artistId] : []; + case 'album': return metadata.albumId ? [metadata.albumId] : []; + case 'genre': return metadata.genreId ? [metadata.genreId] : []; + case 'language': return metadata.language ? [metadata.language] : []; + case 'instrumental': return metadata.instrumental === undefined ? [] : [String(metadata.instrumental)]; + case 'new_artist': return metadata.newArtist ? ['true'] : []; + case 'favorite': return metadata.favorite ? ['true'] : []; + case 'vocal': return metadata.instrumental === undefined ? [] : [String(!metadata.instrumental)]; + case 'producer': return metadata.producerIds ?? []; + case 'label': return metadata.labelIds ?? []; + case 'energy': return metadata.energy === undefined ? [] : [String(Math.min(3, Math.floor(metadata.energy / 0.25)))]; + case 'bpm': return metadata.bpm === undefined ? [] : [String(Math.floor(metadata.bpm / 20))]; + case 'decade': return metadata.decade === undefined ? [] : [String(metadata.decade)]; + case 'mood': return metadata.valence === undefined ? [] : [String(metadata.valence > 0.5)]; + default: return []; + } +} + +function valueForDimension(metadata: CandidateConstraintMetadata | undefined, dimension: string): string | undefined { + return valuesForDimension(metadata, dimension)[0]; +} + +/** Most common known value, but only when it is actually concentrated. */ +export function dominantRecentValue(recent: RecentPlay[], dimension: string): string | undefined { + const counts = new Map(); + for (const play of recent) { + const values = dimension === 'producer' ? play.producerIds + : dimension === 'label' ? play.labelIds + : [dimension === 'artist' ? play.artistId + : dimension === 'album' ? play.albumId + : dimension === 'genre' ? play.genreId + : dimension === 'language' ? play.language + : dimension === 'vocal' && play.vocal != null ? String(play.vocal) + : dimension === 'energy' && play.energy != null ? String(Math.min(3, Math.floor(play.energy / 0.25))) + : dimension === 'bpm' && play.bpm != null ? String(Math.floor(play.bpm / 20)) + : dimension === 'mood' && play.valence != null ? String(play.valence > 0.5) + : dimension === 'decade' && play.decade != null ? String(play.decade) : undefined]; + for (const value of values ?? []) { + if (value) counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + let dominant: string | undefined; + let max = 0; + for (const [value, count] of counts) { + if (count > max) { dominant = value; max = count; } + } + return max >= 2 ? dominant : undefined; +} + +function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata { + return { + artistId: play.artistId ?? undefined, + albumId: play.albumId ?? undefined, + genreId: play.genreId ?? undefined, + language: play.language ?? undefined, + // RecentPlay stores vocal rather than instrumental. Preserve missing audio + // analysis as unknown so it neither triggers nor dodges vocal rules. + instrumental: play.vocal == null ? undefined : !play.vocal, + energy: play.energy ?? undefined, + bpm: play.bpm ?? undefined, + valence: play.valence ?? undefined, + decade: play.decade ?? undefined, + producerIds: play.producerIds ?? [], + labelIds: play.labelIds ?? [], + }; +} + +/** + * The constraint layer is intentionally pure. Ranking supplies its candidate + * order; this layer chooses a feasible sequence and returns every soft rule it + * had to relax. Hard session exclusions are applied before this function and + * are never relaxed here. + */ +export function selectConstrainedSequence(params: { + candidates: Candidate[]; + slots: { position: number; role: string }[]; + metadata: Map; + budgets: DiversityBudget[]; + roleToGeneratorIds: (role: string) => string[]; + /** Already committed queue entries which remain in front of this refill. */ + retainedPlan?: Candidate[]; + /** Completed plays, newest first, used only for the rolling 40-track album cap. */ + albumHistory?: CandidateConstraintMetadata[]; + explicitIntent?: boolean; + loopDimension?: string | null; + loopedValues?: Iterable; + /** @deprecated use loopedValues; retained for callers during migration. */ + loopedValue?: string; +}): ConstrainedPlanResult { + const { + candidates, slots, metadata, budgets, roleToGeneratorIds, retainedPlan = [], + albumHistory = [], explicitIntent = false, loopDimension, loopedValues = [], loopedValue, + } = params; + const relaxations: ConstraintRelaxation[] = []; + const selected: Candidate[] = []; + const selectedIds = new Set(retainedPlan.map(candidate => candidate.trackId)); + const counts = new Map>(); + const budgetByDimension = new Map(budgets.map(b => [b.dimension, b])); + const lowerDimensions = ['instrumental', 'new_artist', 'favorite']; + const softDimensions = ['artist', 'genre', 'language']; + const planLength = retainedPlan.length + slots.length; + const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]); + // Candidate ranking is already stable. Build each role's preferred pool + // once, preserving that order, rather than sorting the whole pool for every + // slot in a long plan. + const preferredPoolCache = new Map(); + const orderedForRole = (role: string): Candidate[] => { + const preferred = roleToGeneratorIds(role); + const key = preferred.join('\u0000'); + const cached = preferredPoolCache.get(key); + if (cached) return cached; + const preferredIds = new Set(preferred); + const ordered = [ + ...candidates.filter(candidate => preferredIds.has(candidate.generatorId)), + ...candidates.filter(candidate => !preferredIds.has(candidate.generatorId)), + ]; + preferredPoolCache.set(key, ordered); + return ordered; + }; + + const count = (dimension: string, value: string | undefined) => + value ? (counts.get(dimension)?.get(value) ?? 0) : 0; + const increment = (dimension: string, values: Iterable) => { + const knownValues = [...values]; + if (knownValues.length === 0) return; + const dimensionCounts = counts.get(dimension) ?? new Map(); + for (const value of knownValues) { + dimensionCounts.set(value, (dimensionCounts.get(value) ?? 0) + 1); + } + counts.set(dimension, dimensionCounts); + }; + const historicalCount = (dimension: string, value: string) => + budgetByDimension.get(dimension)?.historicalValues?.get(value) ?? 0; + const historicalTotal = (dimension: string) => + budgetByDimension.get(dimension)?.historicalTotal ?? 0; + const target = (dimension: string) => { + const budget = budgetByDimension.get(dimension); + if (!budget || explicitIntent) return 0; + // Lower budgets are projected over the actual completed-play sample in the + // configured horizon plus the retained/replacement sequence. `spent` is a + // diagnostic share; counts keep this calculation exact. + const desired = Math.ceil(budget.budgetShare * (historicalTotal(dimension) + planLength)); + return Math.max(0, desired - historicalCount(dimension, 'true')); + }; + const lowerDeficit = (dimension: string) => Math.max(0, target(dimension) - count(dimension, 'true')); + const exceedsUpperLimit = (dimension: string, values: string[]) => { + const budget = budgetByDimension.get(dimension); + if (!budget || explicitIntent || values.length === 0) return false; + const denominator = historicalTotal(dimension) + planLength; + // Preserve a non-zero allowance for a configured category in a short plan. + const limit = Math.max(1, Math.floor(budget.budgetShare * denominator)); + return values.some(value => historicalCount(dimension, value) + count(dimension, value) + 1 > limit); + }; + + // Retained tracks are already exposed to the client, so they must consume + // every sequence budget before a replacement is selected. + for (const retained of retainedPlan) { + const retainedMetadata = metadata.get(retained.trackId); + for (const dimension of ['artist', 'genre', 'language', ...lowerDimensions]) { + increment(dimension, valuesForDimension(retainedMetadata, dimension)); + } + } + // Album is a rolling 40-track horizon, not merely a 20-track plan. Unknown + // identities are deliberately not collapsed into one synthetic album: that + // would reject unrelated tracks. Known IDs can never evade this cap. + // Leave room for the retained/replacement sequence so the window is exactly + // forty tracks at the end of this plan, rather than incorrectly treating a + // 20-track tail as a 60-track lookback. + for (const history of albumHistory.slice(0, Math.max(0, ALBUM_HORIZON_TRACKS - planLength))) { + increment('album', valuesForDimension(history, 'album')); + } + for (const retained of retainedPlan) { + increment('album', valuesForDimension(metadata.get(retained.trackId), 'album')); + } + + for (let position = 0; position < slots.length; position++) { + const preferred = roleToGeneratorIds(slots[position].role); + const ordered = orderedForRole(slots[position].role); + let chosen: Candidate | undefined; + let relaxedSoft = false; + let relaxedArc = false; + + for (let pass = 0; pass < 3 && !chosen; pass++) { + // pass 0: all soft constraints + arc role; pass 1: soft budgets/loop; + // pass 2: permit an arc-source fallback. Hard sequence caps remain. + const relaxSoft = pass >= 1; + const relaxArc = pass >= 2; + for (const candidate of ordered) { + if (selectedIds.has(candidate.trackId)) continue; + const m = metadata.get(candidate.trackId); + const artist = valueForDimension(m, 'artist'); + const album = valueForDimension(m, 'album'); + if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue; + if (album && count('album', album) >= MAX_ALBUM_PER_40_TRACKS) continue; + if (!relaxArc && !preferred.includes(candidate.generatorId)) continue; + + if (!relaxSoft && !explicitIntent) { + if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue; + if (loopDimension && valuesForDimension(m, loopDimension).some(value => loopedValueSet.has(value))) continue; + const remaining = slots.length - position; + const deficits = lowerDimensions.filter(d => lowerDeficit(d) > 0); + if (deficits.length > 0 && remaining <= deficits.reduce((sum, d) => sum + lowerDeficit(d), 0)) { + if (!deficits.some(d => valuesForDimension(m, d).includes('true'))) continue; + } + } + chosen = candidate; + relaxedSoft = relaxSoft; + relaxedArc = relaxArc; + break; + } + } + if (!chosen) break; + if (relaxedSoft && !relaxations.some(r => r.stage === 'soft_budget')) { + relaxations.push({ constraint: loopDimension ?? 'diversity_budget', stage: 'soft_budget', reason: 'eligible inventory could not satisfy projected soft constraints' }); + } + if (relaxedArc && !relaxations.some(r => r.stage === 'arc_precision')) { + relaxations.push({ constraint: 'arc_source', stage: 'arc_precision', reason: 'no hard-feasible candidate matched the requested arc slot' }); + } + selected.push(chosen); + selectedIds.add(chosen.trackId); + const m = metadata.get(chosen.trackId); + for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) { + increment(dimension, valuesForDimension(m, dimension)); + } + } + if (selected.length < slots.length) { + relaxations.push({ constraint: 'freshness', stage: 'freshness', reason: 'hard exclusions and sequence caps left too few eligible candidates' }); + } + return { plan: selected, relaxations }; +} + export class SessionDirector { + // Kept as an instance seam so build/replan integration tests can inspect + // the exact constraint horizon without replacing the planner itself. + private readonly constrainedSequence = selectConstrainedSequence; + constructor(private db: DbService) {} // --------------------------------------------------------------- @@ -235,6 +547,24 @@ export class SessionDirector { artist.set(row.artist_id, row.fatigue); } + // Album fatigue uses the same recent horizon as artist fatigue. It is a + // separate signal: several tracks from a compilation should not exhaust + // every contributing artist, but should still be spread across hours. + const albumRes = await this.db.pgClient.query( + `SELECT t.album_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' + AND ph.completed = true AND t.album_id IS NOT NULL + GROUP BY t.album_id`, + [userId, ARTIST_DECAY_SEC] + ); + const album = new Map(); + for (const row of albumRes.rows as { album_id: string; fatigue: number }[]) { + album.set(row.album_id, row.fatigue); + } + // Genre fatigue: last 24h, decay time constant 8h (e-folding, not half-life; half-life ≈ 5.5h) const genreRes = await this.db.pgClient.query( `SELECT tg.genre_id, @@ -279,7 +609,7 @@ export class SessionDirector { ); const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5; - return { artist, genre, language, track, vocal }; + return { artist, album, genre, language, track, vocal }; } // --------------------------------------------------------------- @@ -305,115 +635,82 @@ export class SessionDirector { const budgets: DiversityBudget[] = []; for (const row of rows) { - const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min); + const usage = await this.loadBudgetUsage(userId, row.dimension, row.horizon_min); budgets.push({ dimension: row.dimension, budgetShare: row.budget_share, horizonMin: row.horizon_min, - spent, + spent: usage.spent, + historicalTotal: usage.total, + historicalValues: usage.values, }); } return budgets; } - private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise { + private async loadBudgetUsage(userId: string, dimension: string, horizonMin: number): Promise<{ + spent: number; + total: number; + values: Map; + }> { const interval = `${horizonMin} minutes`; - - switch (dimension) { - case 'artist': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT COUNT(*) AS cnt - FROM play_history ph - JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - GROUP BY ta.artist_id - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; + // Each source produces at most one known value per completed track. The + // completed-play CTE is deliberately kept separate from classification: + // tracks with sparse metadata stay in the denominator, while only known + // values contribute to a dimension's numerator. + const sourceByDimension: Record = { + artist: `SELECT artist.artist_id::text AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`, + genre: `SELECT genre.genre_id::text AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT tg.genre_id FROM track_genre tg + WHERE tg.track_id = ph.track_id ORDER BY tg.weight DESC, tg.genre_id LIMIT 1) genre ON true`, + language: `SELECT tl.language::text AS value + FROM completed_plays ph LEFT JOIN track_lyrics tl ON tl.track_id = ph.track_id`, + instrumental: `SELECT CASE WHEN taf.instrumentalness >= 0.5 THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id`, + new_artist: `SELECT CASE WHEN artist.artist_id IS NULL THEN NULL WHEN NOT EXISTS ( + SELECT 1 FROM play_history old_ph + JOIN track_artists_v2 old_ta ON old_ta.track_id = old_ph.track_id AND old_ta.role = 'main' + WHERE old_ph.user_id = $1 AND old_ph.completed = true + AND old_ph.played_at <= NOW() - $2::interval AND old_ta.artist_id = artist.artist_id + ) THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`, + favorite: `SELECT CASE WHEN f.track_id IS NOT NULL THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1`, + }; + const source = sourceByDimension[dimension]; + if (!source) return { spent: 0, total: 0, values: new Map() }; + const res = await this.db.pgClient.query( + `WITH completed_plays AS ( + SELECT ph.track_id + FROM play_history ph + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + ), values_per_track AS (${source}) + SELECT value, COUNT(*)::int AS cnt FROM values_per_track + GROUP BY value ORDER BY cnt DESC, value ASC`, + [userId, interval], + ); + const values = new Map(); + let total = 0; + let max = 0; + for (const row of res.rows as { value: string | null; cnt: number | string }[]) { + const count = Number(row.cnt); + total += count; + if (row.value != null) { + values.set(row.value, count); + max = Math.max(max, count); } - case 'genre': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT COUNT(*) AS cnt - FROM play_history ph - JOIN track_genre tg ON tg.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - GROUP BY tg.genre_id - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'language': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT tl.language, COUNT(*) AS cnt - FROM play_history ph - JOIN track_lyrics tl ON tl.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - AND tl.language IS NOT NULL - GROUP BY tl.language - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'instrumental': { - const res = await this.db.pgClient.query( - `SELECT COALESCE( - COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM play_history ph - LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'new_artist': { - const res = await this.db.pgClient.query( - `WITH recent_artists AS ( - SELECT DISTINCT ta.artist_id - FROM play_history ph - JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - ) - SELECT COALESCE( - SUM(CASE WHEN NOT EXISTS ( - SELECT 1 FROM play_history ph3 - JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main' - WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval - AND ta3.artist_id = ra.artist_id - ) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM recent_artists ra`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'favorite': { - const res = await this.db.pgClient.query( - `SELECT COALESCE( - COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM play_history ph - LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1 - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - default: - return 0; } + const lowerBoundDimension = ['instrumental', 'new_artist', 'favorite'].includes(dimension); + const numerator = lowerBoundDimension ? (values.get('true') ?? 0) : max; + return { spent: total > 0 ? numerator / total : 0, total, values }; } // --------------------------------------------------------------- @@ -514,7 +811,7 @@ export class SessionDirector { fatigue: FatigueState, budgets: DiversityBudget[], recentPlays: RecentPlay[] - ): Promise { + ): Promise { const n = recentPlays.length; if (n < 3) return null; @@ -523,8 +820,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1); } - for (const count of artistCounts.values()) { - if (count / n > 0.3) return 'artist'; + for (const [value, count] of artistCounts) { + if (count / n > 0.3) return { dimension: 'artist', values: [value] }; } // 2. GENRE: single genre > 40% of recent plays @@ -532,8 +829,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1); } - for (const count of genreCounts.values()) { - if (count / n > 0.4) return 'genre'; + for (const [value, count] of genreCounts) { + if (count / n > 0.4) return { dimension: 'genre', values: [value] }; } // 3. LANGUAGE: single language > 50% of recent plays @@ -541,8 +838,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1); } - for (const count of langCounts.values()) { - if (count / n > 0.5) return 'language'; + for (const [value, count] of langCounts) { + if (count / n > 0.5) return { dimension: 'language', values: [value] }; } // 4. ENERGY: >60% of plays in same energy quartile @@ -553,7 +850,8 @@ export class SessionDirector { const q = Math.min(Math.floor(e / 0.25), 3); quartileCounts[q]++; } - if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy'; + const dominant = quartileCounts.indexOf(Math.max(...quartileCounts)); + if (quartileCounts[dominant] / energies.length > 0.6) return { dimension: 'energy', values: [String(dominant)] }; } // 5. BPM: all plays within 20 BPM of each other @@ -561,14 +859,17 @@ export class SessionDirector { if (bpms.length >= 3) { const bpmMin = Math.min(...bpms); const bpmMax = Math.max(...bpms); - if (bpmMax - bpmMin <= 20) return 'bpm'; + if (bpmMax - bpmMin <= 20) return { dimension: 'bpm', values: [String(Math.floor(bpms[0] / 20))] }; } // 6. VOCAL: >80% all-vocal or all-instrumental if (n >= 3) { - const vocalCount = recentPlays.filter(p => p.vocal).length; - const vocalRatio = vocalCount / n; - if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal'; + const knownVocal = recentPlays.filter(p => p.vocal != null); + const vocalCount = knownVocal.filter(p => p.vocal).length; + const vocalRatio = knownVocal.length === 0 ? 0.5 : vocalCount / knownVocal.length; + if (knownVocal.length >= 3 && (vocalRatio > 0.8 || vocalRatio < 0.2)) { + return { dimension: 'vocal', values: [String(vocalRatio > 0.8)] }; + } } // 7. DECADE: >50% from same decade @@ -576,44 +877,77 @@ export class SessionDirector { for (const p of recentPlays) { if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1); } - for (const count of decadeCounts.values()) { - if (count / n > 0.5) return 'decade'; + for (const [value, count] of decadeCounts) { + if (count / n > 0.5) return { dimension: 'decade', values: [String(value)] }; } - // 8. PRODUCER: single producer > 3 tracks + // 8. PRODUCER: repeated graph lineage across resolved main artists. These + // edges are artist-to-artist in claim_fusion, so track-subject claims would + // silently never match real enriched data. const trackIds = recentPlays.map(p => p.trackId).filter(Boolean); if (trackIds.length > 0) { const prodRes = await this.db.pgClient.query( - `SELECT c.object_id - FROM claims c - WHERE c.predicate = 'produced' - AND c.subject_id = ANY($1::uuid[]) - GROUP BY c.object_id - HAVING COUNT(DISTINCT c.subject_id) > 3`, + `WITH recent_main_artists AS ( + SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id + FROM play_history ph + JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id + LIMIT 1 + ) artist ON true + WHERE ph.track_id = ANY($1::uuid[]) + ) + SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id + FROM recent_main_artists recent + JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist' + AND cf.predicate = 'produced' + AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id) + GROUP BY lineage_id + HAVING COUNT(DISTINCT recent.track_id) > 3`, [trackIds] ); - if (prodRes.rows.length > 0) return 'producer'; + if (prodRes.rows.length > 0) { + return { dimension: 'producer', values: prodRes.rows.map((row: { lineage_id: string }) => row.lineage_id) }; + } } - // 9. LABEL: single label > 3 tracks + // 9. LABEL: same representation and bidirectional handling as producer + // lineage. `same_label_as` is a graph relationship between artists. if (trackIds.length > 0) { const labelRes = await this.db.pgClient.query( - `SELECT c.object_id - FROM claims c - WHERE c.predicate = 'same_label_as' - AND c.subject_id = ANY($1::uuid[]) - GROUP BY c.object_id - HAVING COUNT(DISTINCT c.subject_id) > 3`, + `WITH recent_main_artists AS ( + SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id + FROM play_history ph + JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id + LIMIT 1 + ) artist ON true + WHERE ph.track_id = ANY($1::uuid[]) + ) + SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id + FROM recent_main_artists recent + JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist' + AND cf.predicate = 'same_label_as' + AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id) + GROUP BY lineage_id + HAVING COUNT(DISTINCT recent.track_id) > 3`, [trackIds] ); - if (labelRes.rows.length > 0) return 'label'; + if (labelRes.rows.length > 0) { + return { dimension: 'label', values: labelRes.rows.map((row: { lineage_id: string }) => row.lineage_id) }; + } } // 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative) const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!); if (valences.length >= 3) { const positiveCount = valences.filter(v => v > 0.5).length; - if (positiveCount === valences.length || positiveCount === 0) return 'mood'; + if (positiveCount === valences.length || positiveCount === 0) { + return { dimension: 'mood', values: [String(positiveCount === valences.length)] }; + } } return null; @@ -717,6 +1051,71 @@ export class SessionDirector { return artistMap; } + /** + * Load the small, planner-facing metadata projection in one round trip. + * This deliberately uses nullable fields: unknown metadata cannot earn a + * lower-bound budget credit, but it remains eligible unless a hard rule has + * enough metadata to apply. + */ + private async loadConstraintMetadata( + userId: string, + trackIds: string[], + ): Promise> { + const metadata = new Map(); + if (trackIds.length === 0) return metadata; + const res = await this.db.pgClient.query( + `SELECT t.id AS track_id, t.album_id, t.release_date, + artist.artist_id, genre.genre_id, tl.language, + taf.instrumentalness, + EXISTS(SELECT 1 FROM favorites f WHERE f.user_id = $1 AND f.track_id = t.id) AS favorite, + NOT EXISTS( + SELECT 1 FROM play_history ph + JOIN track_artists_v2 old_artist ON old_artist.track_id = ph.track_id + AND old_artist.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + AND old_artist.artist_id = artist.artist_id + ) AS new_artist, + taf.energy, taf.bpm, taf.valence, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids + FROM tracks t + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true + LEFT JOIN LATERAL ( + SELECT tg.genre_id FROM track_genre tg + WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1 + ) genre ON true + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + LEFT JOIN track_audio_features taf ON taf.track_id = t.id + WHERE t.id = ANY($2::uuid[])`, + [userId, trackIds], + ); + for (const row of res.rows as Array>) { + const id = row.track_id as string; + metadata.set(id, { + artistId: (row.artist_id as string | null) ?? undefined, + albumId: (row.album_id as string | null) ?? undefined, + genreId: (row.genre_id as string | null) ?? undefined, + language: (row.language as string | null) ?? undefined, + instrumental: typeof row.instrumentalness === 'number' + ? (row.instrumentalness as number) >= 0.5 : undefined, + favorite: row.favorite === true, + newArtist: row.new_artist === true && row.artist_id != null, + energy: (row.energy as number | null) ?? undefined, + bpm: (row.bpm as number | null) ?? undefined, + valence: (row.valence as number | null) ?? undefined, + decade: row.release_date + ? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined, + producerIds: Array.isArray(row.producer_ids) ? row.producer_ids as string[] : [], + labelIds: Array.isArray(row.label_ids) ? row.label_ids as string[] : [], + }); + } + return metadata; + } + // --------------------------------------------------------------- // D.8 — Multi-objective ranking // --------------------------------------------------------------- @@ -725,25 +1124,16 @@ export class SessionDirector { fatigue: FatigueState, budgets: DiversityBudget[], state: GeneratorContext['state'], - repetitionState: RepetitionState + repetitionState: RepetitionState, + userId = '', ): Promise { if (candidates.length === 0) return []; const trackIds = [...new Set(candidates.map(c => c.trackId))]; - const artistMap = await this.loadArtistMap(trackIds); - - const genreMap = new Map(); - if (trackIds.length > 0) { - const genreRes = await this.db.pgClient.query( - `SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id - FROM track_genre tg - WHERE tg.track_id = ANY($1::uuid[]) - ORDER BY tg.track_id, tg.weight DESC`, - [trackIds] - ); - for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) { - genreMap.set(row.track_id, row.genre_id); - } + const metadata = await this.loadConstraintMetadata(userId, trackIds); + const artistMap = new Map(); + for (const [trackId, item] of metadata) { + if (item.artistId) artistMap.set(trackId, item.artistId); } const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown'); @@ -757,15 +1147,22 @@ export class SessionDirector { artistBatchCounts.set(aid, (artistBatchCounts.get(aid) ?? 0) + 1); } - const scored: { candidate: Candidate; score: number }[] = []; - for (const c of candidates) { - const artistId = artistMap.get(c.trackId) ?? ''; - const genreId = genreMap.get(c.trackId) ?? ''; + const scored: { candidate: Candidate; score: number; inputIndex: number }[] = []; + for (const [inputIndex, c] of candidates.entries()) { + const item = metadata.get(c.trackId); + const artistId = item?.artistId ?? ''; + const genreId = item?.genreId ?? ''; + const albumId = item?.albumId ?? ''; + const language = item?.language ?? ''; const trackFatigue = fatigue.track.get(c.trackId) ?? 0; const artistFatigue = fatigue.artist.get(artistId) ?? 0; + const albumFatigue = fatigue.album.get(albumId) ?? 0; const genreFatigue = fatigue.genre.get(genreId) ?? 0; - const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3; + const languageFatigue = fatigue.language.get(language) ?? 0; + const vocalFatigue = item?.instrumental === undefined ? 0 + : (item.instrumental ? 1 - fatigue.vocal : fatigue.vocal); + const avgFatigue = (trackFatigue + artistFatigue + albumFatigue + genreFatigue + languageFatigue + vocalFatigue) / 6; // diversityBonus: this artist's own fatigue-weighted share — varies per candidate. const diversityBonus = 1 - artistFatigue; @@ -785,7 +1182,7 @@ export class SessionDirector { score *= 0.1; } - scored.push({ candidate: c, score }); + scored.push({ candidate: c, score, inputIndex }); } const entropyDrift = Math.abs(currentEntropy - targetEntropy); @@ -801,7 +1198,7 @@ export class SessionDirector { } } - scored.sort((a, b) => b.score - a.score); + scored.sort((a, b) => b.score - a.score || a.inputIndex - b.inputIndex || a.candidate.trackId.localeCompare(b.candidate.trackId)); return scored.map(s => s.candidate); } @@ -835,6 +1232,7 @@ export class SessionDirector { seedTrackId?: string, options: PlanBuildOptions = {} ): Promise { + const retainedPlan = options.retainedPlan ?? []; // Durable events outlive any process-local queue. Fetch them here rather // than trusting callers to remember the boundary, so a served, skipped, // disliked, or otherwise exposed track can never leak into a replacement @@ -852,22 +1250,28 @@ export class SessionDirector { // Fetch recent completed plays for anti-loop detection const recentPlaysRes = await this.db.pgClient.query( - `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id, af.bpm, af.energy, af.valence, af.instrumentalness, tl.language, - t.release_date + t.release_date, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids FROM play_history ph JOIN tracks t ON t.id = ph.track_id LEFT JOIN track_audio_features af ON af.track_id = t.id - LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( SELECT MAX(weight) FROM track_genre WHERE track_id = t.id ) LEFT JOIN track_lyrics tl ON tl.track_id = t.id WHERE ph.user_id = $1 AND ph.completed = true ORDER BY ph.played_at DESC - LIMIT 20`, - [userId] + LIMIT $2`, + [userId, ALBUM_HORIZON_TRACKS] ); const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ trackId: r.track_id, @@ -876,9 +1280,12 @@ export class SessionDirector { bpm: r.bpm ?? null, energy: r.energy ?? null, language: r.language ?? null, - vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5, decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, valence: r.valence ?? null, + albumId: r.album_id ?? null, + producerIds: r.producer_ids ?? [], + labelIds: r.label_ids ?? [], })); const state = await this.buildState(userId, sessionId); @@ -888,7 +1295,7 @@ export class SessionDirector { const arcType = this.pickArc(state); const planSize = PLAN_SIZE; - const slots = this.getArcSlots(arcType, planSize); + const slots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length)); let seedArtistId: string | null = null; if (seedTrackId) { @@ -948,20 +1355,10 @@ export class SessionDirector { return []; } const ranked = await this.rankCandidates( - eligibleCandidates, fatigue, budgets, state, repetitionState + eligibleCandidates, fatigue, budgets, state, repetitionState, userId ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); - let forcedExperimental = false; - if (loopDim && ranked.length > 0) { - // Anti-loop candidates are already present in `ranked` — just pull them to the - // front instead of re-running all generators and re-ranking from scratch. - const injected = ranked.filter( - c => c.generatorId === 'experimental' || c.generatorId === 'discovery' - ); - ranked.unshift(...injected); - forcedExperimental = true; - } const seen = new Set(); const deduped: Candidate[] = []; @@ -972,43 +1369,29 @@ export class SessionDirector { } } - const plan: Candidate[] = []; - const usedTrackIds = new Set(); - - if (!forcedExperimental) { - const unused = [...deduped]; - for (const slot of slots) { - const prefGenIds = this.roleToGeneratorIds(slot.role); - let idx = unused.findIndex( - c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId) - ); - if (idx === -1) { - idx = unused.findIndex(c => !usedTrackIds.has(c.trackId)); - } - if (idx === -1) break; - const chosen = unused[idx]; - usedTrackIds.add(chosen.trackId); - plan.push(chosen); - unused.splice(idx, 1); - } - - if (plan.length < planSize) { - for (const c of deduped) { - if (plan.length >= planSize) break; - if (!usedTrackIds.has(c.trackId)) { - usedTrackIds.add(c.trackId); - plan.push(c); - } - } - } - } else { - for (const c of deduped) { - if (plan.length >= planSize) break; - plan.push(c); - } + const metadata = await this.loadConstraintMetadata(userId, [ + ...new Set([...deduped.map(c => c.trackId), ...retainedPlan.map(c => c.trackId)]), + ]); + const constrained = this.constrainedSequence({ + candidates: deduped, + slots, + metadata, + budgets, + roleToGeneratorIds: role => this.roleToGeneratorIds(role), + retainedPlan, + albumHistory: recentPlays.map(metadataFromRecentPlay), + // A seed is an explicit direction. It narrows soft diversity targets but + // cannot bypass served-track exclusions or artist/album sequence caps. + explicitIntent: !!seedTrackId, + loopDimension: loopDim?.dimension, + loopedValues: loopDim?.values, + }); + if (constrained.relaxations.length > 0) { + // Structured diagnostics stay internal until the durable plan API exposes + // objective snapshots. Do not silently hide a degraded sequence. + console.warn('Vibe constraint relaxations', { sessionId, relaxations: constrained.relaxations }); } - - return plan.slice(0, planSize); + return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length)); } async replan( @@ -1041,14 +1424,20 @@ export class SessionDirector { // Fetch recent plays for anti-loop const recentPlaysRes = await this.db.pgClient.query( - `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id, af.bpm, af.energy, af.valence, af.instrumentalness, tl.language, - t.release_date + t.release_date, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids FROM play_history ph JOIN tracks t ON t.id = ph.track_id LEFT JOIN track_audio_features af ON af.track_id = t.id - LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( SELECT MAX(weight) FROM track_genre WHERE track_id = t.id ) @@ -1065,15 +1454,19 @@ export class SessionDirector { bpm: r.bpm ?? null, energy: r.energy ?? null, language: r.language ?? null, - vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5, decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, valence: r.valence ?? null, + albumId: r.album_id ?? null, + producerIds: r.producer_ids ?? [], + labelIds: r.label_ids ?? [], })); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); if (loopDim) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1083,6 +1476,7 @@ export class SessionDirector { if (Math.abs(entropy - 0.55) > 0.2) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1096,6 +1490,7 @@ export class SessionDirector { // queued. Keep the valid tail and append only fresh candidates. const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 1874694..227f417 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { mergeUniquePlan, SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; import { DbService } from './db.service.js'; +import { ALL_GENERATORS } from './generators.service.js'; function makeMockDb(overrides: Record = {}): DbService { const mockQuery = vi.fn(); @@ -60,6 +61,8 @@ describe('SessionDirector', () => { ); const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set; expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued'])); + expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId)) + .toEqual(['already-queued']); }); it('does not append anything when a refill contains only queued or excluded tracks', async () => { @@ -159,7 +162,7 @@ describe('SessionDirector', () => { { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, ]; - const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }]; const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; @@ -170,6 +173,339 @@ describe('SessionDirector', () => { }); + describe('sequence constraints', () => { + const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' })); + const budgets = [ + { dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }, + { dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 }, + { dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 }, + { dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 }, + { dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 }, + { dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 }, + ]; + const roleToGeneratorIds = () => ['comfort']; + + it('projects budgets while enforcing artist and album caps across the sequence', () => { + const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' })); + const metadata = new Map(candidates.map((item, i) => [item.trackId, { + artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`, + albumId: i < 4 ? 'overplayed-album' : `album-${i}`, + genreId: i < 6 ? 'genre-a' : 'genre-b', + language: i < 7 ? 'ja' : 'en', + instrumental: i === 7, + newArtist: i === 8 || i === 9, + favorite: i === 10 || i === 11 || i === 12, + }])); + + const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds }); + expect(result.plan).toHaveLength(10); + const ids = result.plan.map(item => item.trackId); + expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2); + expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3); + expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1); + expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2); + expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3); + }); + + it('corrects the detected dimension directly before relaxing it', () => { + const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' })); + const metadata = new Map([ + ['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }], + ['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }], + ['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }], + ['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }], + ]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 2), + metadata, + budgets: [], + roleToGeneratorIds, + loopDimension: 'language', + loopedValue: 'ja', + }); + expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']); + expect(result.relaxations).toEqual([]); + }); + + it('records a structured soft relaxation without violating hard album caps', () => { + const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' })); + const metadata = new Map(candidates.map((item, i) => [item.trackId, { + artistId: `artist-${i}`, + albumId: i < 4 ? 'single-album' : `album-${i}`, + language: 'ja', + }])); + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'language', loopedValue: 'ja', + }); + expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3); + expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' })); + }); + + it('counts the retained queue tail against hard artist caps before selecting replacements', () => { + const retained = [candidate('queued-a1'), candidate('queued-a2')]; + const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }], + ['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }], + ['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }], + ['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }], + ]); + + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']); + }); + + it('enforces the three-track album limit across the rolling 40-play history', () => { + const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['same-album', { artistId: 'a4', albumId: 'album-a' }], + ['new-album', { artistId: 'a5', albumId: 'album-b' }], + ]); + const albumHistory = Array.from({ length: 40 }, (_, index) => ({ + artistId: `history-${index}`, + albumId: index < 3 ? 'album-a' : `history-album-${index}`, + })); + + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['new-album']); + }); + + it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => { + const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }], + ['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }], + ]); + const historicalValues = new Map([['j-pop', 4], ['rock', 1]]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 1), + metadata, + budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }], + roleToGeneratorIds, + }); + // 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would + // exceed the 60% cap. The selector must use history + proposal, not only + // the one-track replacement queue. + expect(result.plan.map(item => item.trackId)).toEqual(['en']); + }); + + it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => { + const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['unknown', { artistId: 'a1', albumId: 'x1' }], + ['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }], + ]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 1), + metadata, + budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }], + roleToGeneratorIds, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']); + }); + + it('excludes candidates matching any detected producer or label, not only their first claim', () => { + const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }], + ['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }], + ['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }], + ]); + const producerResult = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'producer', loopedValues: ['producer-loop'], + }); + const labelResult = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'label', loopedValues: ['label-loop'], + }); + expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match'); + expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match'); + }); + + it('keeps stable candidate order while reusing a role preference pool', () => { + const candidates = [ + { ...candidate('comfort-first'), generatorId: 'comfort' }, + { ...candidate('adjacent-first'), generatorId: 'adjacent' }, + { ...candidate('comfort-second'), generatorId: 'comfort' }, + { ...candidate('adjacent-second'), generatorId: 'adjacent' }, + ]; + const metadata = new Map(candidates.map((item, index) => [item.trackId, { + artistId: `artist-${index}`, albumId: `album-${index}`, + }])); + const result = selectConstrainedSequence({ + candidates, + slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })), + metadata, + budgets: [], + roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'], + }); + expect(result.plan.map(item => item.trackId)).toEqual([ + 'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second', + ]); + }); + }); + + describe('anti-loop signals', () => { + const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({ + trackId: `00000000-0000-0000-0000-00000000000${index + 1}`, + artistId: `artist-${index}`, + genreId: `genre-${index}`, + language: `lang-${index}`, + bpm: 80 + index * 30, + energy: index / 3, + vocal: null, + decade: 1980 + index * 10, + valence: index % 2, + albumId: `album-${index}`, + producerIds: [], + labelIds: [], + })); + + it('returns fused producer lineage from resolved main artists', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] }); + const director = new SessionDirector(db); + const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays); + expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] }); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('claim_fusion cf'); + expect(sql).toContain("cf.subject_type = 'artist'"); + expect(sql).toContain('cf.object_id = recent.artist_id'); + expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id'); + }); + + it('returns label identities after a producer check finds no loop', async () => { + const db = makeMockDb(); + (db.pgClient.query as any) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] }); + const director = new SessionDirector(db); + const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays); + expect(signal).toEqual({ dimension: 'label', values: ['label-a'] }); + const sql = (db.pgClient.query as any).mock.calls[1][0] as string; + expect(sql).toContain("cf.predicate = 'same_label_as'"); + expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id'); + }); + }); + + describe('planner metadata and integration boundaries', () => { + it('counts every completed play in a budget horizon while only classifying known values', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }], + }); + const director = new SessionDirector(db); + const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30); + expect(usage).toMatchObject({ total: 5, spent: 0.6 }); + expect(usage.values).toEqual(new Map([['rock', 3]])); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('WITH completed_plays AS'); + expect(sql).not.toContain('WHERE value IS NOT NULL'); + }); + + it('loads producer and label lineage from fused relationships of the resolved main artist', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ + track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null, + language: null, instrumentalness: null, favorite: false, new_artist: false, + energy: null, bpm: null, valence: null, release_date: null, + producer_ids: ['producer-from-object', 'producer-from-subject'], + label_ids: ['label-from-object', 'label-from-subject'], + }], + }); + const director = new SessionDirector(db); + const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']); + expect(metadata.get('track-1')).toMatchObject({ + artistId: 'artist-1', + producerIds: ['producer-from-object', 'producer-from-subject'], + labelIds: ['label-from-object', 'label-from-subject'], + }); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('FROM claim_fusion cf'); + expect(sql).toContain("cf.predicate = 'produced'"); + expect(sql).toContain("cf.predicate = 'same_label_as'"); + expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id'); + expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id'); + }); + + it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => { + const db = makeMockDb({ + getVibeSessionTrackIds: vi.fn().mockResolvedValue([]), + getListenerBeliefs: vi.fn().mockResolvedValue([]), + }); + const director = new SessionDirector(db); + const history = Array.from({ length: 40 }, (_, index) => ({ + track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`, + artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null, + valence: null, instrumentalness: null, language: null, release_date: null, + producer_ids: [], label_ids: [], + })); + (db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => { + if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history }); + if (sql.includes('WHERE t.id = ANY($2::uuid[])')) { + const ids = params[1] as string[]; + return Promise.resolve({ rows: ids.map(trackId => ({ + track_id: trackId, + artist_id: `artist-${trackId}`, + album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`, + genre_id: null, language: null, instrumentalness: null, favorite: false, + new_artist: false, energy: null, bpm: null, valence: null, release_date: null, + producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [], + label_ids: [], + })) }); + } + return Promise.resolve({ rows: [] }); + }); + vi.spyOn(director, 'buildState').mockResolvedValue({ + energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null, + }); + vi.spyOn(director, 'computeFatigue').mockResolvedValue({ + artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5, + }); + vi.spyOn(director, 'getBudgets').mockResolvedValue([]); + vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() }); + vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates); + vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] }); + vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map()); + + const originalGenerators = [...ALL_GENERATORS]; + ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [ + { ...candidate('producer-loop-candidate'), generatorId: 'comfort' }, + { ...candidate('history-album-candidate'), generatorId: 'comfort' }, + ...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })), + ...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })), + ...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })), + ]); + let captured: any; + vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => { + captured = params; + return selectConstrainedSequence(params); + }); + try { + const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' })); + const plan = await director.replan('user-1', 'session-1', retained, []); + expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId)); + expect(captured.albumHistory).toHaveLength(40); + expect(captured.loopDimension).toBe('producer'); + expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']); + expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate'); + expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0'); + } finally { + ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators); + } + }); + }); + describe('buildState', () => { it('returns state with default values when no prior session', async () => { const db = makeMockDb(); From fe13798c99033ed7f2c0079f68a1d1b4dd1b6b15 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 2 Aug 2026 00:49:27 +0400 Subject: [PATCH 6/8] feat(vibe): plan musical arcs and callbacks --- .../src/routes/vibe-sessions.routes.test.ts | 17 + backend/src/routes/vibe-sessions.routes.ts | 6 + backend/src/services/generators.service.ts | 14 + .../src/services/session-director.service.ts | 519 +++++++++++++++++- backend/src/services/session-director.test.ts | 256 ++++++++- .../vibe-session-coordinator.service.test.ts | 61 +- .../vibe-session-coordinator.service.ts | 79 ++- 7 files changed, 910 insertions(+), 42 deletions(-) diff --git a/backend/src/routes/vibe-sessions.routes.test.ts b/backend/src/routes/vibe-sessions.routes.test.ts index a9300b5..f739f4d 100644 --- a/backend/src/routes/vibe-sessions.routes.test.ts +++ b/backend/src/routes/vibe-sessions.routes.test.ts @@ -68,6 +68,23 @@ describe('durable Vibe session routes', () => { await app.close(); }); + it('reserves track_served for the authoritative /next operation', async () => { + const { app, coordinator } = await appWithCoordinator(); + const result = await app.inject({ + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, + payload: { + type: 'track_served', + trackId: TRACK_ID, + payload: { planVersionId: '33333333-3333-4333-8333-333333333333', ordinal: 0 }, + }, + }); + + expect(result.statusCode).toBe(400); + expect(result.json()).toEqual({ error: 'track_served is reserved for the server /next operation' }); + expect(coordinator.appendEvent).not.toHaveBeenCalled(); + await app.close(); + }); + it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => { const { app, coordinator } = await appWithCoordinator(); coordinator.start.mockRejectedValueOnce( diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index a477340..6e2b326 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -101,6 +101,12 @@ export default async function vibeSessionsRoutes( if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) { return reply.code(400).send({ error: 'type must be a supported Vibe event type' }); } + // Delivery is an authoritative state transition performed only by /next. + // Accepting this event from the public ledger endpoint would let a client + // fabricate exposure rows and consume the server-side surprise budget. + if (body.type === 'track_served') { + return reply.code(400).send({ error: 'track_served is reserved for the server /next operation' }); + } if (body.eventId !== undefined && !validUuid(body.eventId)) { return reply.code(400).send({ error: 'eventId must be a UUID' }); } diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 1077563..8612b5f 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -20,6 +20,20 @@ export interface Candidate { generatorId: string; explanation: ClaimEdge[]; relevance: number; + /** + * Filled by the session director after sequence planning. Generators remain + * deliberately unaware of slots and objectives, while the durable plan can + * retain why this particular candidate won its position. + */ + plan?: { + slotRole: string; + score: number; + scoreBreakdown: Record; + explanation: Record; + /** Revision-level policy and constraint evidence, copied into the durable + * objective snapshot by the coordinator. */ + objective?: Record; + }; } export interface GeneratorContext { diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 4e07647..e04c621 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -22,6 +22,8 @@ export interface RecentPlay { vocal: boolean | null; decade: number | null; valence: number | null; + acousticness?: number | null; + instrumentalness?: number | null; albumId?: string | null; producerIds?: string[]; labelIds?: string[]; @@ -40,6 +42,8 @@ export interface CandidateConstraintMetadata { energy?: number; bpm?: number; valence?: number; + acousticness?: number; + instrumentalness?: number; decade?: number; producerIds?: string[]; labelIds?: string[]; @@ -87,6 +91,46 @@ export interface PlanBuildOptions { retainedPlan?: Candidate[]; } +export interface ArcRange { + min?: number; + max?: number; + /** Preferred delta from the immediately preceding track. */ + maxDelta?: number; +} + +export interface CallbackToken { + id: string; + phase: 'anchor' | 'return'; + /** The return may use the anchor artist, genre, or simply a familiar item. */ + theme: 'artist' | 'genre' | 'favorite'; + minSeparation: number; + maxSeparation: number; +} + +export interface SurpriseDirective { + /** A surprise is bounded and must be followed by a recovery anchor. */ + recoveryRole: string; + maxPerPlan: number; + maxPerHour: number; +} + +export interface ArcSlot { + position: number; + role: string; + /** Optional only for legacy callers; getArcSlots always supplies targets. */ + targets?: { + energy?: ArcRange; + tempo?: ArcRange; + valence?: ArcRange; + acousticness?: ArcRange; + instrumentality?: ArcRange; + /** 0 familiar, 1 exploratory. Derived from source and known metadata. */ + novelty?: ArcRange; + }; + callback?: CallbackToken; + surprise?: SurpriseDirective; +} + const W_ENJOY = 1.0; const W_FATIGUE = 0.4; const W_DIVERSITY = 0.3; @@ -208,12 +252,106 @@ function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata { energy: play.energy ?? undefined, bpm: play.bpm ?? undefined, valence: play.valence ?? undefined, + acousticness: play.acousticness ?? undefined, + instrumentalness: play.instrumentalness ?? undefined, decade: play.decade ?? undefined, producerIds: play.producerIds ?? [], labelIds: play.labelIds ?? [], }; } +function candidateNovelty(candidate: Candidate, metadata: CandidateConstraintMetadata | undefined): number { + if (metadata?.favorite) return 0.05; + if (metadata?.newArtist) return 0.8; + if (candidate.generatorId === 'discovery') return 0.7; + if (candidate.generatorId === 'adjacent') return 0.45; + if (candidate.generatorId === 'deep-dive') return 0.2; + return 0.3; +} + +function rangeScore(value: number | undefined, range: ArcRange | undefined): { score: number; known: boolean } { + if (!range) return { score: 1, known: true }; + if (value === undefined || !Number.isFinite(value)) return { score: 0.5, known: false }; + const min = range.min ?? -Infinity; + const max = range.max ?? Infinity; + if (value >= min && value <= max) return { score: 1, known: true }; + const distance = value < min ? min - value : value - max; + // A target miss is a soft degradation. It is never an eligibility failure. + return { score: Math.max(0, 1 - distance / 0.5), known: true }; +} + +/** + * Unknown analysis must remain playable, but it cannot displace a candidate + * whose measured audio features satisfy this slot. This is intentionally + * limited to absolute targets: `maxDelta` is a transition preference, not an + * independent eligibility rule. + */ +function matchesMeasuredArcTargets( + metadata: CandidateConstraintMetadata | undefined, + slot: ArcSlot, +): boolean { + const targets = slot.targets ?? {}; + const dimensions: Array<[number | undefined, ArcRange | undefined]> = [ + [metadata?.energy, targets.energy], + [metadata?.bpm, targets.tempo], + [metadata?.valence, targets.valence], + [metadata?.acousticness, targets.acousticness], + [metadata?.instrumentalness, targets.instrumentality], + ]; + const absolute = dimensions.filter(([, range]) => range && (range.min !== undefined || range.max !== undefined)); + if (absolute.length === 0) return false; + return absolute.every(([value, range]) => { + if (value === undefined || !Number.isFinite(value)) return false; + return value >= (range!.min ?? -Infinity) && value <= (range!.max ?? Infinity); + }); +} + +function hasAbsoluteArcTargets(slot: ArcSlot): boolean { + const targets = slot.targets ?? {}; + return [targets.energy, targets.tempo, targets.valence, targets.acousticness, targets.instrumentality] + .some(range => range && (range.min !== undefined || range.max !== undefined)); +} + +/** A local DJ transition is useful even with partial analysis: unknown values + * lower confidence, whereas known values are evaluated smoothly. */ +export function scoreArcTransition( + candidate: Candidate, + candidateMetadata: CandidateConstraintMetadata | undefined, + previousMetadata: CandidateConstraintMetadata | undefined, + slot: ArcSlot, +): { score: number; confidence: number; targetScore: number; transitionScore: number } { + const targetsForSlot = slot.targets ?? {}; + const targets = [ + rangeScore(candidateMetadata?.energy, targetsForSlot.energy), + rangeScore(candidateMetadata?.bpm, targetsForSlot.tempo), + rangeScore(candidateMetadata?.valence, targetsForSlot.valence), + rangeScore(candidateMetadata?.acousticness, targetsForSlot.acousticness), + rangeScore(candidateMetadata?.instrumentalness, targetsForSlot.instrumentality), + rangeScore(candidateNovelty(candidate, candidateMetadata), targetsForSlot.novelty), + ]; + const targetScore = targets.reduce((total, item) => total + item.score, 0) / targets.length; + const knownTargets = targets.filter(item => item.known).length; + const deltas: Array<[number | undefined, number | undefined, ArcRange | undefined, number]> = [ + [candidateMetadata?.energy, previousMetadata?.energy, targetsForSlot.energy, 0.3], + [candidateMetadata?.bpm, previousMetadata?.bpm, targetsForSlot.tempo, 35], + [candidateMetadata?.valence, previousMetadata?.valence, targetsForSlot.valence, 0.3], + [candidateMetadata?.acousticness, previousMetadata?.acousticness, targetsForSlot.acousticness, 0.3], + [candidateMetadata?.instrumentalness, previousMetadata?.instrumentalness, targetsForSlot.instrumentality, 0.3], + ]; + const knownDeltas = deltas.filter(([current, previous]) => current !== undefined && previous !== undefined); + const transitionScore = knownDeltas.length === 0 ? 0.5 : knownDeltas.reduce((total, [current, previous, target, defaultMaxDelta]) => { + const maxDelta = target?.maxDelta ?? defaultMaxDelta; + return total + Math.max(0, 1 - Math.abs(current! - previous!) / maxDelta); + }, 0) / knownDeltas.length; + const confidence = (knownTargets + knownDeltas.length) / (targets.length + deltas.length); + return { + score: targetScore * 0.65 + transitionScore * 0.35, + confidence, + targetScore, + transitionScore, + }; +} + /** * The constraint layer is intentionally pure. Ranking supplies its candidate * order; this layer chooses a feasible sequence and returns every soft rule it @@ -222,7 +360,7 @@ function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata { */ export function selectConstrainedSequence(params: { candidates: Candidate[]; - slots: { position: number; role: string }[]; + slots: ArcSlot[]; metadata: Map; budgets: DiversityBudget[]; roleToGeneratorIds: (role: string) => string[]; @@ -249,6 +387,10 @@ export function selectConstrainedSequence(params: { const softDimensions = ['artist', 'genre', 'language']; const planLength = retainedPlan.length + slots.length; const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]); + let previousMetadata = retainedPlan.length > 0 + ? metadata.get(retainedPlan[retainedPlan.length - 1].trackId) + : undefined; + const callbackAnchors = new Map(); // Candidate ranking is already stable. Build each role's preferred pool // once, preserving that order, rather than sorting the whole pool for every // slot in a long plan. @@ -322,26 +464,105 @@ export function selectConstrainedSequence(params: { increment('album', valuesForDimension(metadata.get(retained.trackId), 'album')); } + const hardConstraints = { + artistMaxPerPlan: MAX_ARTIST_PER_PLAN, + albumMaxPer40Tracks: MAX_ALBUM_PER_40_TRACKS, + uniqueTracks: true, + }; + for (let position = 0; position < slots.length; position++) { - const preferred = roleToGeneratorIds(slots[position].role); - const ordered = orderedForRole(slots[position].role); + let slot = slots[position]; let chosen: Candidate | undefined; + let chosenTransition: ReturnType | undefined; + let chosenCallbackScore = 0; + let chosenSurpriseScore = 0; + let chosenSelectionScore = 0; + let chosenRankScore = 0; let relaxedSoft = false; let relaxedArc = false; + let downgradedSurpriseGenerators: Set | undefined; + + /** A surprise is only admitted if its declared recovery role still has a + * hard-feasible anchor after the surprise itself has consumed its caps. */ + const canReserveRecovery = (surpriseCandidate: Candidate, surpriseMetadata: CandidateConstraintMetadata | undefined) => { + if (!slot.surprise) return true; + const recoveryGenerators = new Set(roleToGeneratorIds(slot.surprise.recoveryRole)); + const surpriseArtist = valueForDimension(surpriseMetadata, 'artist'); + const surpriseAlbum = valueForDimension(surpriseMetadata, 'album'); + return candidates.some(recovery => { + if (recovery.trackId === surpriseCandidate.trackId || selectedIds.has(recovery.trackId)) return false; + if (!recoveryGenerators.has(recovery.generatorId)) return false; + const recoveryMetadata = metadata.get(recovery.trackId); + const artist = valueForDimension(recoveryMetadata, 'artist'); + const album = valueForDimension(recoveryMetadata, 'album'); + const prospectiveArtistCount = artist && artist === surpriseArtist ? 1 : 0; + const prospectiveAlbumCount = album && album === surpriseAlbum ? 1 : 0; + return (!artist || count('artist', artist) + prospectiveArtistCount < MAX_ARTIST_PER_PLAN) + && (!album || count('album', album) + prospectiveAlbumCount < MAX_ALBUM_PER_40_TRACKS); + }); + }; + + // A surprise whose recovery cannot be reserved is downgraded before + // selection. This prevents an unrecoverable high-risk pick from being + // written to a revision merely because the later recovery slot is empty. + if (slot.surprise) { + const surpriseGenerators = new Set(roleToGeneratorIds(slot.role)); + const reservable = candidates.some(candidate => + !selectedIds.has(candidate.trackId) + && surpriseGenerators.has(candidate.generatorId) + && canReserveRecovery(candidate, metadata.get(candidate.trackId)), + ); + if (!reservable) { + relaxations.push({ + constraint: 'surprise_recovery', + stage: 'arc_precision', + reason: 'no hard-feasible recovery anchor remained for the requested surprise', + }); + downgradedSurpriseGenerators = surpriseGenerators; + slot = { + ...slot, + role: slot.surprise.recoveryRole, + surprise: undefined, + targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } }, + }; + } + } for (let pass = 0; pass < 3 && !chosen; pass++) { // pass 0: all soft constraints + arc role; pass 1: soft budgets/loop; // pass 2: permit an arc-source fallback. Hard sequence caps remain. const relaxSoft = pass >= 1; const relaxArc = pass >= 2; - for (const candidate of ordered) { + const preferred = roleToGeneratorIds(slot.role); + const ordered = orderedForRole(slot.role); + let best: { candidate: Candidate; transition: ReturnType; callbackScore: number; surpriseScore: number; score: number; rankScore: number } | undefined; + const fittingArcCandidateExists = hasAbsoluteArcTargets(slot) && ordered.some(candidate => { + if (selectedIds.has(candidate.trackId)) return false; + const m = metadata.get(candidate.trackId); + const artist = valueForDimension(m, 'artist'); + const album = valueForDimension(m, 'album'); + return (!artist || count('artist', artist) < MAX_ARTIST_PER_PLAN) + && (!album || count('album', album) < MAX_ALBUM_PER_40_TRACKS) + && matchesMeasuredArcTargets(m, slot); + }); + for (const [candidateIndex, candidate] of ordered.entries()) { if (selectedIds.has(candidate.trackId)) continue; + // Do not relabel the rejected high-risk item as a recovery merely + // because source relaxation is allowed for the downgraded slot. + if (downgradedSurpriseGenerators?.has(candidate.generatorId)) continue; const m = metadata.get(candidate.trackId); const artist = valueForDimension(m, 'artist'); const album = valueForDimension(m, 'album'); if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue; if (album && count('album', album) >= MAX_ALBUM_PER_40_TRACKS) continue; - if (!relaxArc && !preferred.includes(candidate.generatorId)) continue; + // A surprise is never silently satisfied by an arbitrary comfort + // fallback. If no explainable exploratory source is available the + // planner reports a shortened/degraded sequence instead. + if ((!relaxArc || slot.surprise) && !preferred.includes(candidate.generatorId)) continue; + // Arc measurements are an enforceable preference when the catalog + // gives us a hard-feasible measured fit. Sparse analysis falls back + // gracefully and is documented as an arc-precision relaxation below. + if (fittingArcCandidateExists && !matchesMeasuredArcTargets(m, slot)) continue; if (!relaxSoft && !explicitIntent) { if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue; @@ -352,30 +573,134 @@ export function selectConstrainedSequence(params: { if (!deficits.some(d => valuesForDimension(m, d).includes('true'))) continue; } } - chosen = candidate; + const transition = scoreArcTransition(candidate, m, previousMetadata, slot); + const anchor = slot.callback ? callbackAnchors.get(slot.callback.id) : undefined; + let callbackScore = 0; + if (slot.callback?.phase === 'return' && anchor) { + const separation = position - anchor.position; + const themeMatches = slot.callback.theme === 'favorite' + ? m?.favorite === true + : slot.callback.theme === 'artist' + ? !!m?.artistId && m.artistId === anchor.metadata.artistId + : !!m?.genreId && m.genreId === anchor.metadata.genreId; + // The token is preference-only: a hard diversity/repetition rule may + // still make a literal return impossible, in which case familiarity + // provides a graceful callback rather than a failed plan. + const inWindow = separation >= slot.callback.minSeparation && separation <= slot.callback.maxSeparation; + callbackScore = inWindow && themeMatches ? 1 : (m?.favorite ? 0.35 : 0); + } + const surpriseScore = slot.surprise + ? (candidate.generatorId === 'discovery' || candidate.generatorId === 'adjacent' || m?.newArtist ? 1 : 0) + : 0; + if (slot.surprise && !canReserveRecovery(candidate, m)) continue; + // Keep ranking meaningful, but let the sequence controller prefer a + // fitting continuation over the next independent highest-ranked song. + const rankScore = 1 - candidateIndex / Math.max(1, ordered.length); + const score = rankScore * 0.45 + transition.score * 0.4 + callbackScore * 0.1 + surpriseScore * 0.05; + if (!best || score > best.score || (score === best.score && candidate.trackId.localeCompare(best.candidate.trackId) < 0)) { + best = { candidate, transition, callbackScore, surpriseScore, score, rankScore }; + } + } + if (best) { + chosen = best.candidate; + chosenTransition = best.transition; + chosenCallbackScore = best.callbackScore; + chosenSurpriseScore = best.surpriseScore; + chosenSelectionScore = best.score; + chosenRankScore = best.rankScore; relaxedSoft = relaxSoft; relaxedArc = relaxArc; - break; } } if (!chosen) break; if (relaxedSoft && !relaxations.some(r => r.stage === 'soft_budget')) { relaxations.push({ constraint: loopDimension ?? 'diversity_budget', stage: 'soft_budget', reason: 'eligible inventory could not satisfy projected soft constraints' }); } - if (relaxedArc && !relaxations.some(r => r.stage === 'arc_precision')) { + if ((hasAbsoluteArcTargets(slot) && !matchesMeasuredArcTargets(metadata.get(chosen.trackId), slot)) + && !relaxations.some(r => r.stage === 'arc_precision' && r.constraint === 'arc_precision')) { + relaxations.push({ constraint: 'arc_precision', stage: 'arc_precision', reason: 'no hard-feasible measured candidate satisfied the requested arc targets' }); + } + if (relaxedArc && !relaxations.some(r => r.constraint === 'arc_source')) { relaxations.push({ constraint: 'arc_source', stage: 'arc_precision', reason: 'no hard-feasible candidate matched the requested arc slot' }); } - selected.push(chosen); + const chosenMetadata = metadata.get(chosen.trackId); + const decorated: Candidate = { + ...chosen, + plan: { + slotRole: slot.role, + score: chosenSelectionScore || (chosenTransition ? chosenTransition.score : chosen.relevance), + scoreBreakdown: { + relevance: chosen.relevance, + rankingPositionScore: chosenRankScore, + transition: chosenTransition?.transitionScore ?? 0.5, + arcTarget: chosenTransition?.targetScore ?? 0.5, + transitionConfidence: chosenTransition?.confidence ?? 0, + callback: chosenCallbackScore, + surprise: chosenSurpriseScore, + selection: chosenSelectionScore, + weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 }, + }, + explanation: { + arcRole: slot.role, + targets: slot.targets, + callback: slot.callback ? { + id: slot.callback.id, + phase: slot.callback.phase, + theme: slot.callback.theme, + minSeparation: slot.callback.minSeparation, + maxSeparation: slot.callback.maxSeparation, + matchScore: chosenCallbackScore, + } : null, + surprise: slot.surprise ? { + recoveryRole: slot.surprise.recoveryRole, + maxPerPlan: slot.surprise.maxPerPlan, + maxPerHour: slot.surprise.maxPerHour, + selectionScore: chosenSurpriseScore, + } : null, + arcPrecision: { + measuredFit: matchesMeasuredArcTargets(chosenMetadata, slot), + enforcedBecauseFitExists: hasAbsoluteArcTargets(slot), + }, + }, + }, + }; + selected.push(decorated); selectedIds.add(chosen.trackId); - const m = metadata.get(chosen.trackId); + const m = chosenMetadata; for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) { increment(dimension, valuesForDimension(m, dimension)); } + previousMetadata = chosenMetadata; + if (slot.callback?.phase === 'anchor' && chosenMetadata) { + callbackAnchors.set(slot.callback.id, { metadata: chosenMetadata, position }); + } } if (selected.length < slots.length) { relaxations.push({ constraint: 'freshness', stage: 'freshness', reason: 'hard exclusions and sequence caps left too few eligible candidates' }); } - return { plan: selected, relaxations }; + const policy = { + selection: 'rank_transition_callback_surprise_v1', + weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 }, + arcTargets: 'enforced_when_a_hard_feasible_measured_fit_exists', + surprise: 'requires_reserved_recovery_anchor', + }; + const constraints = { + hard: hardConstraints, + softBudgetDimensions: [...softDimensions, ...lowerDimensions], + loopDimension: loopDimension ?? null, + }; + const objective = { policy, constraints, relaxations }; + return { + plan: selected.map(candidate => ({ + ...candidate, + plan: candidate.plan ? { + ...candidate.plan, + explanation: { ...candidate.plan.explanation, policy, constraints, relaxations }, + objective, + } : candidate.plan, + })), + relaxations, + }; } export class SessionDirector { @@ -723,27 +1048,77 @@ export class SessionDirector { return 'comfort'; } - getArcSlots(arcType: string, count: number): { position: number; role: string }[] { + getArcSlots(arcType: string, count: number): ArcSlot[] { const pattern = this.getArcPattern(arcType); - const slots: { position: number; role: string }[] = []; + const slots: ArcSlot[] = []; for (let i = 0; i < count; i++) { - slots.push({ position: i, role: pattern[i % pattern.length] }); + const template = pattern[i % pattern.length]; + // A repeated template must not replay an old callback token. The token + // identity is scoped to the cycle so each return has one clear anchor. + const cycle = Math.floor(i / pattern.length); + slots.push({ + ...template, + position: i, + callback: template.callback ? { ...template.callback, id: `${template.callback.id}:${cycle}` } : undefined, + surprise: template.surprise && cycle === 0 ? template.surprise : undefined, + }); } return slots; } - private getArcPattern(arcType: string): string[] { + private getArcPattern(arcType: string): Omit[] { + const familiar = { novelty: { max: 0.35 } }; + const adjacent = { novelty: { min: 0.2, max: 0.65 } }; + const newDiscovery = { novelty: { min: 0.55, max: 1 } }; + const callbackAnchor: CallbackToken = { + id: 'comfort-theme', phase: 'anchor', theme: 'artist', minSeparation: 2, maxSeparation: 6, + }; + const callbackReturn: CallbackToken = { + ...callbackAnchor, phase: 'return', + }; + const surprise: SurpriseDirective = { recoveryRole: 'favorite', maxPerPlan: 1, maxPerHour: 1 }; + const energeticSurprise: SurpriseDirective = { recoveryRole: 'cooldown', maxPerPlan: 1, maxPerHour: 1 }; switch (arcType) { case 'comfort': - return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + return [ + { role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.2 }, tempo: { maxDelta: 25 }, ...familiar } }, + { role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.18 }, tempo: { maxDelta: 22 }, ...familiar } }, + { role: 'adjacent', targets: { energy: { min: 0.35, max: 0.7, maxDelta: 0.22 }, tempo: { maxDelta: 28 }, ...adjacent } }, + { role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, valence: { maxDelta: 0.3 }, ...familiar }, callback: callbackAnchor }, + { role: 'adjacent', targets: { energy: { min: 0.35, max: 0.75, maxDelta: 0.25 }, ...adjacent } }, + { role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, ...familiar }, callback: callbackReturn }, + { role: 'surprise', targets: { energy: { min: 0.25, max: 0.75, maxDelta: 0.3 }, ...newDiscovery }, surprise }, + { role: 'favorite', targets: { energy: { min: 0.25, max: 0.65, maxDelta: 0.25 }, acousticness: { min: 0.1 }, ...familiar } }, + ]; case 'discovery': - return ['favorite', 'similar', 'new', 'favorite']; + return [ + { role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackAnchor }, + { role: 'similar', targets: { energy: { maxDelta: 0.25 }, tempo: { maxDelta: 30 }, ...adjacent } }, + { role: 'surprise', targets: { energy: { maxDelta: 0.3 }, ...newDiscovery }, surprise }, + { role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackReturn }, + ]; case 'energetic': - return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown']; + return [ + { role: 'medium', targets: { energy: { min: 0.45, max: 0.7, maxDelta: 0.18 }, tempo: { min: 95, max: 145, maxDelta: 25 }, ...familiar } }, + { role: 'medium', targets: { energy: { min: 0.5, max: 0.75, maxDelta: 0.15 }, tempo: { min: 100, max: 155, maxDelta: 20 }, ...adjacent } }, + { role: 'high', targets: { energy: { min: 0.65, max: 0.9, maxDelta: 0.22 }, tempo: { min: 110, max: 175, maxDelta: 28 }, ...adjacent } }, + { role: 'high', targets: { energy: { min: 0.7, max: 0.95, maxDelta: 0.18 }, tempo: { min: 115, max: 180, maxDelta: 25 }, ...newDiscovery } }, + { role: 'peak', targets: { energy: { min: 0.8, max: 1, maxDelta: 0.2 }, tempo: { min: 120, max: 190, maxDelta: 30 }, valence: { min: 0.45, maxDelta: 0.3 }, ...newDiscovery } }, + { role: 'surprise', targets: { energy: { min: 0.7, max: 1, maxDelta: 0.25 }, tempo: { maxDelta: 35 }, ...newDiscovery }, surprise: energeticSurprise }, + { role: 'cooldown', targets: { energy: { min: 0.45, max: 0.75, maxDelta: 0.3 }, tempo: { maxDelta: 35 }, ...familiar } }, + { role: 'cooldown', targets: { energy: { min: 0.35, max: 0.65, maxDelta: 0.2 }, acousticness: { min: 0.1 }, ...familiar } }, + ]; case 'late-night': - return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow']; + return [ + { role: 'soft', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 115, maxDelta: 20 }, acousticness: { min: 0.2 }, ...familiar } }, + { role: 'soft', targets: { energy: { max: 0.38, maxDelta: 0.12 }, tempo: { max: 110, maxDelta: 18 }, ...familiar } }, + { role: 'ambient', targets: { energy: { max: 0.3, maxDelta: 0.15 }, tempo: { max: 100, maxDelta: 18 }, instrumentality: { min: 0.35 }, ...adjacent } }, + { role: 'ambient', targets: { energy: { max: 0.28, maxDelta: 0.1 }, acousticness: { min: 0.25 }, instrumentality: { min: 0.35 }, ...adjacent } }, + { role: 'acoustic', targets: { energy: { max: 0.45, maxDelta: 0.2 }, acousticness: { min: 0.35 }, ...familiar }, callback: callbackAnchor }, + { role: 'slow', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 105, maxDelta: 18 }, ...familiar }, callback: callbackReturn }, + ]; default: - return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + return this.getArcPattern('comfort'); } } @@ -766,6 +1141,8 @@ export class SessionDirector { return ['discovery']; case 'peak': return ['deep-dive', 'contextual']; + case 'surprise': + return ['discovery', 'adjacent']; case 'ambient': return ['contextual', 'comfort']; default: @@ -1075,7 +1452,7 @@ export class SessionDirector { WHERE ph.user_id = $1 AND ph.completed = true AND old_artist.artist_id = artist.artist_id ) AS new_artist, - taf.energy, taf.bpm, taf.valence, + taf.energy, taf.bpm, taf.valence, taf.acousticness, taf.instrumentalness, ${lineageIdsSql('produced', 'artist')} AS producer_ids, ${lineageIdsSql('same_label_as', 'artist')} AS label_ids FROM tracks t @@ -1107,6 +1484,8 @@ export class SessionDirector { energy: (row.energy as number | null) ?? undefined, bpm: (row.bpm as number | null) ?? undefined, valence: (row.valence as number | null) ?? undefined, + acousticness: (row.acousticness as number | null) ?? undefined, + instrumentalness: (row.instrumentalness as number | null) ?? undefined, decade: row.release_date ? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined, producerIds: Array.isArray(row.producer_ids) ? row.producer_ids as string[] : [], @@ -1223,6 +1602,48 @@ export class SessionDirector { ); } + /** + * The surprise budget is consumed when a surprise is actually delivered to + * playback, not when it is merely present in a preview. Replans copy the + * unserved tail into a new immutable revision, so counting `slot_role` + * rows would charge the same unserved surprise once per revision. The + * `track_served` event is the durable, exactly-once delivery boundary; its + * plan-version payload lets us identify the precise immutable item that + * was exposed. + */ + private async getSessionSurpriseUsage(sessionId: string, userId: string): Promise<{ session: number; hour: number }> { + const res = await this.db.pgClient.query( + `WITH surprise_exposures AS ( + SELECT DISTINCT e.id, e.occurred_at + FROM vibe_events e + JOIN vibe_plan_items item + ON item.plan_version_id = CASE + WHEN e.payload->>'planVersionId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN (e.payload->>'planVersionId')::uuid + END + AND item.ordinal = CASE + WHEN (e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$' + OR ((e.payload->>'ordinal') ~ '^1[0-9]{9}$' AND (e.payload->>'ordinal') <= '2147483647') + THEN (e.payload->>'ordinal')::integer + END + AND item.track_id = e.track_id + JOIN vibe_plan_versions version + ON version.id = item.plan_version_id + AND version.session_id = e.session_id + WHERE e.session_id = $1 + AND e.user_id = $2 + AND e.type = 'track_served' + AND item.slot_role = 'surprise' + ) + SELECT COUNT(*)::int AS session_count, + COUNT(*) FILTER (WHERE occurred_at > NOW() - INTERVAL '1 hour')::int AS hour_count + FROM surprise_exposures`, + [sessionId, userId], + ); + const row = res.rows[0] as { session_count?: number | string; hour_count?: number | string } | undefined; + return { session: Number(row?.session_count ?? 0), hour: Number(row?.hour_count ?? 0) }; + } + // --------------------------------------------------------------- // D.9 — Plan + replan loop // --------------------------------------------------------------- @@ -1251,7 +1672,7 @@ export class SessionDirector { // Fetch recent completed plays for anti-loop detection const recentPlaysRes = await this.db.pgClient.query( `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id, - af.bpm, af.energy, af.valence, af.instrumentalness, + af.bpm, af.energy, af.valence, af.acousticness, af.instrumentalness, tl.language, t.release_date, ${lineageIdsSql('produced', 'artist')} AS producer_ids, @@ -1283,6 +1704,8 @@ export class SessionDirector { vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5, decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, valence: r.valence ?? null, + acousticness: r.acousticness ?? null, + instrumentalness: r.instrumentalness ?? null, albumId: r.album_id ?? null, producerIds: r.producer_ids ?? [], labelIds: r.label_ids ?? [], @@ -1295,7 +1718,9 @@ export class SessionDirector { const arcType = this.pickArc(state); const planSize = PLAN_SIZE; - const slots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length)); + const rawSlots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length)); + const surpriseUsage = await this.getSessionSurpriseUsage(sessionId, userId); + const slots = this.applySurpriseBudget(rawSlots, surpriseUsage, state.sessionAgeMin); let seedArtistId: string | null = null; if (seedTrackId) { @@ -1337,6 +1762,21 @@ export class SessionDirector { if (allCandidates.length === 0) { return []; } + const availableGeneratorIds = new Set(allCandidates.map(candidate => candidate.generatorId)); + // A budgeted surprise without an explainable discovery candidate becomes + // its declared recovery anchor before ranking. This is a deterministic + // degradation, not a random or mislabeled fallback. + const effectiveSlots = slots.map(slot => { + const hasSurpriseSource = !slot.surprise + || this.roleToGeneratorIds(slot.role).some(id => availableGeneratorIds.has(id)); + if (hasSurpriseSource || !slot.surprise) return slot; + return { + ...slot, + role: slot.surprise.recoveryRole, + surprise: undefined, + targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } }, + }; + }); const repetitionState = await this.buildRepetitionState(userId); const candidateArtistMap = await this.loadArtistMap( @@ -1374,7 +1814,7 @@ export class SessionDirector { ]); const constrained = this.constrainedSequence({ candidates: deduped, - slots, + slots: effectiveSlots, metadata, budgets, roleToGeneratorIds: role => this.roleToGeneratorIds(role), @@ -1394,6 +1834,37 @@ export class SessionDirector { return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length)); } + /** Apply the durable delivery budget to fresh arc slots. Retained entries + * are intentionally absent here: they have not consumed anything until a + * `track_served` event exists for their immutable revision. */ + private applySurpriseBudget( + rawSlots: ArcSlot[], + surpriseUsage: { session: number; hour: number }, + sessionAgeMin: number, + ): ArcSlot[] { + // One surprise is allowed in a rolling hour. The session capacity grows + // slowly (one per listening hour), preserving a recovery anchor after + // every admitted surprise instead of filling each replan with novelty. + const sessionSurpriseLimit = Math.max(1, Math.ceil(Math.max(0, sessionAgeMin) / 60)); + let plannedSurprises = 0; + return rawSlots.map(slot => { + if (!slot.surprise) return slot; + const allowed = plannedSurprises < slot.surprise.maxPerPlan + && surpriseUsage.hour + plannedSurprises < slot.surprise.maxPerHour + && surpriseUsage.session + plannedSurprises < sessionSurpriseLimit; + if (allowed) { + plannedSurprises++; + return slot; + } + return { + ...slot, + role: slot.surprise.recoveryRole, + surprise: undefined, + targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } }, + }; + }); + } + async replan( userId: string, sessionId: string, diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 227f417..581035d 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { mergeUniquePlan, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; import { DbService } from './db.service.js'; import { ALL_GENERATORS } from './generators.service.js'; @@ -122,9 +122,261 @@ describe('SessionDirector', () => { it('has valid role names', () => { const slots = director.getArcSlots('comfort', 20); - const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow']; + const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise']; slots.forEach(s => expect(validRoles).toContain(s.role)); }); + + it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => { + const slots = director.getArcSlots('comfort', 20); + expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true); + expect(slots.filter(slot => slot.surprise)).toHaveLength(1); + const anchor = slots.find(slot => slot.callback?.phase === 'anchor'); + const callback = slots.find(slot => slot.callback?.phase === 'return'); + expect(anchor?.callback?.id).toBe(callback?.callback?.id); + expect(anchor?.callback?.minSeparation).toBeGreaterThan(0); + }); + }); + + describe('durable surprise delivery accounting', () => { + it('counts only exact, served surprise plan-item exposures', async () => { + const sessionId = '00000000-0000-4000-8000-000000000001'; + const userId = '00000000-0000-4000-8000-000000000002'; + const otherSessionId = '00000000-0000-4000-8000-000000000003'; + const revisionOneId = '00000000-0000-4000-8000-000000000011'; + const revisionTwoId = '00000000-0000-4000-8000-000000000012'; + const otherRevisionId = '00000000-0000-4000-8000-000000000013'; + const now = new Date(); + const recentAt = new Date(now.getTime() - 5 * 60 * 1000); + const expiredAt = new Date(now.getTime() - 61 * 60 * 1000); + const retainedTrackId = '00000000-0000-4000-8000-000000000021'; + const servedTrackId = '00000000-0000-4000-8000-000000000022'; + const oldTrackId = '00000000-0000-4000-8000-000000000023'; + + // This mirrors the three tables involved in the query. The unserved + // retained row exists in both immutable revisions, but has no ledger + // event and therefore must not consume a surprise budget. + const versions = [ + { id: revisionOneId, sessionId }, + { id: revisionTwoId, sessionId }, + { id: otherRevisionId, sessionId: otherSessionId }, + ]; + const items = [ + { planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' }, + { planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' }, + { planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' }, + { planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' }, + { planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' }, + { planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' }, + ]; + const events = [ + // The exact revision-two association counts once. + { id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, + // A valid historical exposure remains in the session total but falls + // out of the rolling 60-minute counter. + { id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt }, + { id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal + { id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track + { id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version + { id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session + { id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, + // Legacy/corrupt payloads must neither cast-fail nor claim an actual + // surprise exposure when somebody writes directly to the event ledger. + { id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt }, + { id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt }, + { id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt }, + ]; + const exposureIds: string[] = []; + const db = makeMockDb(); + (db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => { + // Faithfully evaluate the query's joins against the in-memory rows; + // do not treat merely planned items as delivered exposure. + expect(params).toEqual([sessionId, userId]); + const matching = events.filter(event => { + const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId + && candidate.ordinal === event.payload.ordinal + && candidate.trackId === event.trackId); + const version = item && versions.find(candidate => candidate.id === item.planVersionId); + return event.sessionId === sessionId + && event.userId === userId + && event.type === 'track_served' + && item?.slotRole === 'surprise' + && version?.sessionId === event.sessionId; + }); + exposureIds.push(...new Set(matching.map(event => event.id))); + return Promise.resolve({ + rows: [{ + session_count: new Set(matching.map(event => event.id)).size, + hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size, + }], + }); + }); + + const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId); + + expect(usage).toEqual({ session: 2, hour: 1 }); + expect(exposureIds).toEqual([ + '00000000-0000-4000-8000-000000000031', + '00000000-0000-4000-8000-000000000032', + ]); + const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]]; + expect(params).toEqual([sessionId, userId]); + expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at'); + expect(sql).toContain("e.payload->>'planVersionId' ~*"); + expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid"); + expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'"); + expect(sql).toContain("THEN (e.payload->>'ordinal')::integer"); + expect(sql).toContain('AND item.track_id = e.track_id'); + expect(sql).toContain('AND version.session_id = e.session_id'); + expect(sql).toContain("e.type = 'track_served'"); + expect(sql).toContain("item.slot_role = 'surprise'"); + expect(sql).toContain('WHERE e.session_id = $1'); + expect(sql).toContain('AND e.user_id = $2'); + expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'"); + }); + }); + + describe('transition-aware sequence scoring', () => { + it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => { + const slot = { + position: 0, + role: 'high', + targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } }, + }; + const previous = { energy: 0.72, bpm: 132 }; + const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot); + const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot); + const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot); + + expect(smooth.score).toBeGreaterThan(abrupt.score); + expect(unknown.confidence).toBeLessThan(smooth.confidence); + expect(unknown.score).toBeGreaterThan(0); + }); + + it('selects a callback inside its separation window while preserving hard caps', () => { + const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' })); + const metadata = new Map([ + ['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }], + ['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }], + ['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }], + ['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }], + ['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }], + ]); + const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 }; + const result = selectConstrainedSequence({ + candidates, + metadata, + budgets: [], + roleToGeneratorIds: () => ['comfort'], + slots: [ + { position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } }, + { position: 1, role: 'known', targets: {} }, + { position: 2, role: 'known', targets: {} }, + { position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } }, + ], + }); + expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']); + expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1); + }); + + it('plans an energetic rise through peak and cooldown when measured candidates exist', () => { + const db = makeMockDb(); + const director = new SessionDirector(db); + const slots = director.getArcSlots('energetic', 8); + const entries = [ + ['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125], + ['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150], + ['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155], + ['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110], + ] as const; + const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId })); + const metadata: Map = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, { + artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm, + valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1, + favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'), + newArtist: generatorId === 'discovery', + }])); + const result = selectConstrainedSequence({ + candidates, slots, metadata, budgets: [], + roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role), + }); + + expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId)); + expect(result.plan.map(item => metadata.get(item.trackId)?.energy)) + .toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]); + expect(result.relaxations).toEqual([]); + }); + + it('keeps the familiar-new-familiar discovery callback intact', () => { + const db = makeMockDb(); + const director = new SessionDirector(db); + const candidates = [ + { ...candidate('favorite-anchor'), generatorId: 'deep-dive' }, + { ...candidate('adjacent'), generatorId: 'adjacent' }, + { ...candidate('new'), generatorId: 'discovery' }, + { ...candidate('favorite-return'), generatorId: 'deep-dive' }, + ]; + const metadata = new Map([ + ['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }], + ['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }], + ['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }], + ['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }], + ]); + const result = selectConstrainedSequence({ + candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [], + roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role), + }); + + expect(result.plan.map(item => item.trackId)) + .toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']); + expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 }); + }); + + it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => { + const db = makeMockDb(); + const director = new SessionDirector(db); + const candidates = [ + { ...candidate('favorite-anchor'), generatorId: 'deep-dive' }, + { ...candidate('adjacent'), generatorId: 'adjacent' }, + { ...candidate('unrecoverable-surprise'), generatorId: 'discovery' }, + // This may fill the downgraded favourite slot only through the + // explicitly persisted arc-source relaxation; it cannot reserve a + // recovery for the surprise because it is not a favourite source. + { ...candidate('fallback'), generatorId: 'contextual' }, + ]; + const metadata = new Map(candidates.map((item, index) => [item.trackId, { + artistId: `artist-${index}`, albumId: `album-${index}`, + favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5, + }])); + const result = selectConstrainedSequence({ + candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [], + roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role), + }); + + expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']); + expect(result.plan[2].plan?.slotRole).toBe('favorite'); + expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' })); + }); + + it('falls back safely with sparse audio analysis and records arc precision', () => { + const db = makeMockDb(); + const director = new SessionDirector(db); + const result = selectConstrainedSequence({ + candidates: [ + { ...candidate('unknown-analysis'), generatorId: 'comfort' }, + { ...candidate('wrong-energy'), generatorId: 'comfort' }, + ], + slots: director.getArcSlots('energetic', 1), + metadata: new Map([ + ['unknown-analysis', { artistId: 'a1', albumId: 'x1' }], + ['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }], + ]), + budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role), + }); + + expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']); + expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' })); + expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false }); + }); }); describe('computeEntropy', () => { diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index a1f5e4f..ecf149e 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -54,6 +54,7 @@ function setup() { const director = { buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]), buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }), + replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]), } as any; return { db, director, coordinator: new VibeSessionCoordinator(db, director) }; } @@ -80,6 +81,31 @@ describe('VibeSessionCoordinator', () => { .toEqual(['session_started']); }); + it('persists a director-selected arc role and explainable sequence score', async () => { + const { db, director, coordinator } = setup(); + (director.buildPlan as any).mockResolvedValueOnce([{ + trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }], + plan: { + slotRole: 'surprise', score: 0.82, + scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 }, + explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } }, + }, + }]); + + await coordinator.start('user-1', {}); + + expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ + items: [expect.objectContaining({ + slot_role: 'surprise', score: 0.82, + score_breakdown: expect.objectContaining({ transition: 0.9 }), + explanation: expect.objectContaining({ + paths: [{ predicate: 'near' }], + planner: expect.objectContaining({ arcRole: 'surprise' }), + }), + })], + })); + }); + it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => { const { db, coordinator } = setup(); (db.recordVibeEvent as any).mockResolvedValueOnce({ @@ -118,13 +144,42 @@ describe('VibeSessionCoordinator', () => { const { db, director, coordinator } = setup(); const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); - expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, TRACK_ID); + expect(director.replan).toHaveBeenCalledWith( + 'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID, + { excludedTrackIds: new Set() }, + ); expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })], })); expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); }); + it('passes the durable unserved callback tail into the retention-aware replan', async () => { + const { db, director, coordinator } = setup(); + (db.getVibePlan as any).mockResolvedValue({ + ...plan(), + items: [{ + ...plan().items[0], + slot_role: 'favorite', + explanation: { + paths: [], + planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } }, + }, + }], + }); + + await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' }); + + expect(director.replan).toHaveBeenCalledWith( + 'user-1', SESSION_ID, + [expect.objectContaining({ + trackId: TRACK_ID, + plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }), + })], + ['other-track'], 'other-track', { excludedTrackIds: new Set() }, + ); + }); + it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => { const { db, director, coordinator } = setup(); const seedTrackId = '44444444-4444-4444-8444-444444444444'; @@ -132,9 +187,11 @@ describe('VibeSessionCoordinator', () => { await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); - expect(director.buildPlan).toHaveBeenCalledWith( + expect(director.replan).toHaveBeenCalledWith( 'user-1', SESSION_ID, + expect.any(Array), + [TRACK_ID], TRACK_ID, { excludedTrackIds: new Set([seedTrackId]) }, ); diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index c52f846..39779d7 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -1,5 +1,6 @@ import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; +import { Candidate } from './generators.service.js'; /** * This is deliberately a narrow bridge between the durable Vibe ledger and @@ -67,7 +68,7 @@ export interface VibeSessionResponse { export class VibeSessionCoordinator { constructor( private readonly db: DbService, - private readonly director: Pick, + private readonly director: Pick, ) {} async start(userId: string, input: StartVibeSessionInput): Promise { @@ -111,15 +112,18 @@ export class VibeSessionCoordinator { policyVersion, intent: input.intent ?? null, horizonTracks: candidates.length, + ...(candidates[0]?.plan?.objective ?? {}), }, items: candidates.map((candidate, ordinal) => ({ ordinal, track_id: candidate.trackId, - slot_role: ordinal === 0 ? 'next' : null, + slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null), candidate_source: candidate.generatorId, - score: candidate.relevance, - score_breakdown: { relevance: candidate.relevance }, - explanation: candidate.explanation, + score: candidate.plan?.score ?? candidate.relevance, + score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance }, + explanation: candidate.plan + ? { paths: candidate.explanation, planner: candidate.plan.explanation } + : candidate.explanation, committed: false, })), }); @@ -216,11 +220,24 @@ export class VibeSessionCoordinator { // feedback tracks, the seed is not necessarily present in the event // ledger, so carry it explicitly into every replacement request. const seedTrackId = session.seed_track_id ?? undefined; - const candidates = seedTrackId - ? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, { - excludedTrackIds: new Set([seedTrackId]), - }) - : await this.director.buildPlan(userId, sessionId, input.trackId); + // Revisions retain the durable, unserved queue tail rather than building + // an unrelated plan after every signal. Besides reducing churn, this + // preserves a valid callback/recovery pair that has already been shown + // to the client while allowing the director to refill under the same + // hard caps and current feedback state. + const current = await this.db.getVibePlan(sessionId, userId); + const retained = current ? this.unservedCandidates(current) : []; + const excludedTrackIds = new Set([ + ...(seedTrackId ? [seedTrackId] : []), + ]); + const candidates = await this.director.replan( + userId, + sessionId, + retained, + input.trackId ? [input.trackId] : [], + input.trackId ?? seedTrackId, + { excludedTrackIds }, + ); const reason = `feedback:${input.type}`; const plan = await this.db.publishVibePlan({ sessionId, @@ -232,15 +249,18 @@ export class VibeSessionCoordinator { feedbackEventId: result.event.id, feedbackType: input.type, horizonTracks: candidates.length, + ...(candidates[0]?.plan?.objective ?? {}), }, items: candidates.map((candidate, ordinal) => ({ ordinal, track_id: candidate.trackId, - slot_role: ordinal === 0 ? 'next' : null, + slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null), candidate_source: candidate.generatorId, - score: candidate.relevance, - score_breakdown: { relevance: candidate.relevance }, - explanation: candidate.explanation, + score: candidate.plan?.score ?? candidate.relevance, + score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance }, + explanation: candidate.plan + ? { paths: candidate.explanation, planner: candidate.plan.explanation } + : candidate.explanation, committed: false, })), }); @@ -303,6 +323,37 @@ export class VibeSessionCoordinator { }; } + /** Reconstruct the planner envelope from the durable revision. Older + * revisions stored only paths, so they remain valid retention inputs. */ + private unservedCandidates(plan: VibePlan): Candidate[] { + return plan.items + .filter(item => !item.committed) + .map(item => { + const stored = item.explanation; + const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object' + && 'planner' in stored; + const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record } : undefined; + const planner = object?.planner; + return { + trackId: item.track_id, + generatorId: item.candidate_source, + relevance: item.score, + explanation: object?.paths ?? (Array.isArray(stored) ? stored : []), + plan: planner ? { + slotRole: item.slot_role ?? 'retained', + score: item.score, + scoreBreakdown: item.score_breakdown, + explanation: planner, + objective: { + policy: planner.policy, + constraints: planner.constraints, + relaxations: planner.relaxations, + }, + } : undefined, + }; + }); + } + private mapLifecycleError(error: unknown): Error { if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) { return new VibeSessionLifecycleError(error.message); From 61a1373ca9d794636037d695fc54eb54ff4a8414 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 2 Aug 2026 02:08:35 +0400 Subject: [PATCH 7/8] feat(vibe): adapt sessions to context and exploration --- backend/src/db/migrations.test.ts | 16 ++ backend/src/db/migrations.ts | 39 +++ backend/src/db/schema.sql | 25 ++ backend/src/db/types.ts | 13 + backend/src/routes/vibe-sessions.routes.ts | 9 + backend/src/services/db.service.test.ts | 146 +++++++++++- backend/src/services/db.service.ts | 223 +++++++++++++++++- backend/src/services/generators.service.ts | 4 + .../src/services/session-director.service.ts | 68 +++++- backend/src/services/session-director.test.ts | 20 +- .../src/services/vibe-context.service.test.ts | 36 +++ backend/src/services/vibe-context.service.ts | 120 ++++++++++ .../vibe-session-coordinator.service.test.ts | 38 +++ .../vibe-session-coordinator.service.ts | 41 +++- 14 files changed, 782 insertions(+), 16 deletions(-) create mode 100644 backend/src/services/vibe-context.service.test.ts create mode 100644 backend/src/services/vibe-context.service.ts diff --git a/backend/src/db/migrations.test.ts b/backend/src/db/migrations.test.ts index 148c336..6c2462f 100644 --- a/backend/src/db/migrations.test.ts +++ b/backend/src/db/migrations.test.ts @@ -43,3 +43,19 @@ describe('Vibe durable session migration', () => { expect(migration!.sql).toContain('idx_vibe_events_user_occurred'); }); }); + +describe('Vibe context memory migration', () => { + it('adds bounded session exploration state and exactly-once projection storage', () => { + const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration'); + expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles'); + expect(migration?.sql).toContain('exploration_coefficient'); + expect(migration?.sql).toContain('vibe_session_feedback_projections'); + }); + + it('backfills profiles for durable sessions created before context memory', () => { + const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill'); + expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles'); + expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions'); + expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING'); + }); +}); diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index ca57ab3..7ea215e 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -720,4 +720,43 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + // Session-specific exploration, goals, and deliberately lossy session + // fingerprints are derived from the immutable Vibe ledger. Keeping them + // separate from listener_beliefs prevents a transient session from + // rewriting permanent taste. + id: '20260802_vibe_context_memory_exploration', + sql: ` + CREATE TABLE IF NOT EXISTS vibe_session_profiles ( + session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb, + goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb, + exploration_coefficient REAL NOT NULL DEFAULT 0.30 + CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1), + discovery_radius REAL NOT NULL DEFAULT 0.38 + CHECK (discovery_radius >= 0 AND discovery_radius <= 1), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated + ON vibe_session_profiles (user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `, + }, + { + // The profile table was introduced after durable sessions. Backfill every + // pre-existing session before feedback can claim its exactly-once marker; + // newly created sessions receive their context-derived initial goals in + // createVibeSession's transaction. + id: '20260802_vibe_session_profile_backfill', + sql: ` + INSERT INTO vibe_session_profiles (session_id, user_id) + SELECT id, user_id FROM vibe_sessions + ON CONFLICT (session_id) DO NOTHING; + `, + }, ]; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 105936a..dcbba8b 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -543,6 +543,23 @@ CREATE TABLE IF NOT EXISTS vibe_sessions ( CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event ON vibe_sessions (user_id, last_event_at DESC); +-- Compact, derived memory for the session director. Fingerprints are a +-- deliberately lossy description of session shape (not a track list) and are +-- used only as a soft freshness signal against recent sessions. +CREATE TABLE IF NOT EXISTS vibe_session_profiles ( + session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb, + goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb, + exploration_coefficient REAL NOT NULL DEFAULT 0.30 + CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1), + discovery_radius REAL NOT NULL DEFAULT 0.38 + CHECK (discovery_radius >= 0 AND discovery_radius <= 1), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated + ON vibe_session_profiles (user_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS vibe_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), client_event_id UUID, @@ -570,6 +587,14 @@ CREATE TABLE IF NOT EXISTS vibe_event_projections ( projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Separate from listener-belief projection because exploration is session +-- state. It lets a failed post-event replan safely retry the exact same +-- adaptation without counting the feedback twice. +CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS vibe_plan_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index fc51708..6a45f17 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -258,3 +258,16 @@ export interface VibePlanItem { export interface VibePlan extends VibePlanVersion { items: VibePlanItem[]; } + +/** Derived session-director memory. The ledger remains authoritative; this + * compact row makes fingerprints, bounded goals, and exploration state cheap + * to read while planning. */ +export interface VibeSessionProfile { + session_id: string; + user_id: string; + fingerprint: Record; + goals: Record; + exploration_coefficient: number; + discovery_radius: number; + updated_at: Date; +} diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index 6e2b326..8e0eaa7 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -6,6 +6,7 @@ import { VibeSessionNotFoundError, VibePlanNotFoundError, } from '../services/vibe-session-coordinator.service.js'; +import { isValidVibeContext } from '../services/vibe-context.service.js'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -56,6 +57,9 @@ export default async function vibeSessionsRoutes( if (body.context !== undefined && !isObject(body.context)) { return reply.code(400).send({ error: 'context must be an object' }); } + if (isObject(body.context) && !isValidVibeContext(body.context)) { + return reply.code(400).send({ error: 'context contains an invalid structured Vibe value' }); + } if (body.intent !== undefined && typeof body.intent !== 'string') { return reply.code(400).send({ error: 'intent must be a string' }); } @@ -125,6 +129,11 @@ export default async function vibeSessionsRoutes( if (body.payload !== undefined && !isObject(body.payload)) { return reply.code(400).send({ error: 'payload must be an object' }); } + if (body.type === 'context_changed' && isObject(body.payload) + && body.payload.context !== undefined + && (!isObject(body.payload.context) || !isValidVibeContext(body.payload.context))) { + return reply.code(400).send({ error: 'context_changed payload.context must be structured Vibe context' }); + } try { return reply.send(await coordinator.appendEvent(userId, sessionId, { eventId: body.eventId as string | undefined, diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 8ce3ace..fecad8e 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -19,6 +19,46 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType describe('DbService v2 methods', () => { describe('durable Vibe sessions', () => { + it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1', + type: 'completed', occurred_at: new Date(), client_event_id: null, + position_ms: null, duration_ms: null, payload: {}, + } as any; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill + .mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker + .mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity + .mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence + .mockResolvedValueOnce({ rowCount: 1 }) // discovery belief + .mockResolvedValueOnce({ rows: [] }) // no artist/genre targets + .mockResolvedValueOnce({ rows: [] }) // no audio targets + .mockResolvedValueOnce({ rows: [{ + exploration_coefficient: 0.36, discovery_radius: 0.434, + goals: { type: 'familiar', target: 1, progress: 1 }, + }] }) + .mockResolvedValueOnce({ rows: [] }) // session_state projection + .mockResolvedValueOnce({ rows: [] }) // COMMIT + .mockResolvedValueOnce({ rows: [] }) // BEGIN retry + .mockResolvedValueOnce({ rows: [] }) // profile upsert retry + .mockResolvedValueOnce({ rows: [] }) // marker conflict + .mockResolvedValueOnce({ rows: [] }); // COMMIT retry + + await service.projectVibeSessionFeedback(event); + await service.projectVibeSessionFeedback(event); + + expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles'); + expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections'); + expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history'); + expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence'); + expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient'); + expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END'); + expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 })); + expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1); + }); + it('creates, reads, and ends sessions scoped to their user', async () => { const { service, poolQuery, clientQuery } = makeTransactionalService(); const session = { @@ -44,12 +84,59 @@ describe('DbService v2 methods', () => { expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock'); expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE"); expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions'); - expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']); + expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([ + 'user-1', null, expect.any(String), 'v2.1', + JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, + ])); + expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({ + activity: 'focus', + })); expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2'); expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())'); expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); }); + it('normalizes context at the persistence boundary for direct callers', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z')); + const { service, clientQuery } = makeTransactionalService(); + const session = { id: 'session-1', user_id: 'user-1', status: 'active' }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // user advisory lock + .mockResolvedValueOnce({ rows: [] }) // active-session lock + .mockResolvedValueOnce({ rows: [session] }) // insert + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + try { + await service.createVibeSession({ + userId: 'user-1', + policyVersion: 'v2.1', + context: { + timeZone: 'UTC', + activity: 'walking', + device: 'phone', + exactCoordinates: '53.1959,50.1002', + browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' }, + localHour: 3, + weekday: 1, + }, + }); + + const insertParameters = clientQuery.mock.calls[3][1]; + expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]); + expect(JSON.parse(insertParameters[2])).toEqual({ + timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend', + activity: 'walking', device: 'phone', + }); + expect(insertParameters.slice(3)).toEqual([ + 'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, + ]); + } finally { + vi.useRealTimers(); + } + }); + it('replaces an owned active session and writes its terminal event before starting another', async () => { const { service, clientQuery } = makeTransactionalService(); const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' }; @@ -102,6 +189,63 @@ describe('DbService v2 methods', () => { ); }); + it('sanitizes and projects an inserted context change atomically with its ledger event', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null, + type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { context: { activity: 'walking', localHour: 12 } }, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock + .mockResolvedValueOnce({ rows: [event] }) // insert + .mockResolvedValueOnce({ rows: [] }) // session context projection + .mockResolvedValueOnce({ rows: [] }) // legacy session-state projection + .mockResolvedValueOnce({ rows: [] }) // last event timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', type: 'context_changed', + payload: { + context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, + rawBrowserTelemetry: { battery: 4 }, + }, + }); + + const values = clientQuery.mock.calls[2][1]; + const storedPayload = JSON.parse(values[8]); + expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) }); + expect(storedPayload.context).not.toHaveProperty('exactCoordinates'); + expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry'); + expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb'); + expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'"); + }); + + it('does not apply a retry body to an existing context-change event', async () => { + const { service, clientQuery } = makeTransactionalService(); + const canonicalEvent = { + id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null, + type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { context: { activity: 'focus', localHour: 12 } }, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock + .mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const result = await service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed', + payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } }, + }); + + expect(result).toEqual({ event: canonicalEvent, inserted: false }); + expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain( + expect.stringContaining('SET context = $3::jsonb'), + ); + }); + it('projects material feedback once with the durable event transaction', async () => { const { service, clientQuery } = makeTransactionalService(); const event = { diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 7c3cb60..6c0a890 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { Pool, PoolClient } from 'pg'; import { SearchService } from './search.service.js'; +import { normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; @@ -51,6 +52,7 @@ import type { RecordedVibeEvent, VibePlan, VibePlanItem, + VibeSessionProfile, } from '../db/types.js'; import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js'; export * from '../db/types.js'; @@ -1514,7 +1516,17 @@ export class DbService { policyVersion: string; seedTrackId?: string | null; context?: Record; + profile?: { + goals: Record; + explorationCoefficient: number; + discoveryRadius: number; + }; }): Promise { + // DbService is also used directly by workers and migrations. Keep the + // durable storage boundary canonical even when callers bypass the HTTP + // coordinator, so opaque or precise client telemetry can never become + // session context. + const canonicalContext = normalizeVibeContext(params.context ?? {}); return this.withTransaction(async (client) => { // Serialize starts for one listener even when there is no active row to // lock yet. The row lock below then safely replaces any prior session. @@ -1542,14 +1554,25 @@ export class DbService { } } const res = await client.query( - `INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) - VALUES ($1, 'active', $2, $3::jsonb, $4) - RETURNING *`, + `WITH created AS ( + INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) + VALUES ($1, 'active', $2, $3::jsonb, $4) + RETURNING * + ), profile AS ( + INSERT INTO vibe_session_profiles + (session_id, user_id, goals, exploration_coefficient, discovery_radius) + SELECT id, user_id, $5::jsonb, $6::real, $7::real FROM created + ON CONFLICT (session_id) DO NOTHING + ) + SELECT * FROM created`, [ params.userId, params.seedTrackId ?? null, - JSON.stringify(params.context ?? {}), + JSON.stringify(canonicalContext), params.policyVersion, + JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }), + params.profile?.explorationCoefficient ?? 0.3, + params.profile?.discoveryRadius ?? 0.38, ], ); return res.rows[0] as VibeSession; @@ -1565,6 +1588,141 @@ export class DbService { return (res.rows[0] as VibeSession) ?? null; } + /** Recent session shapes, excluding the active session. The fingerprint is + * intentionally aggregate-only and is used as a soft planning penalty. */ + async getRecentVibeSessionFingerprints(userId: string, sessionId: string, limit = 8): Promise[]> { + const res = await this.pgClient.query( + `SELECT p.fingerprint + FROM vibe_session_profiles p + JOIN vibe_sessions s ON s.id = p.session_id + WHERE p.user_id = $1 AND p.session_id <> $2::uuid + AND s.status IN ('ended', 'expired', 'replaced') + AND p.fingerprint <> '{}'::jsonb + ORDER BY p.updated_at DESC + LIMIT $3`, + [userId, sessionId, limit], + ); + return res.rows.map((row: { fingerprint: Record }) => row.fingerprint ?? {}); + } + + async getVibeSessionProfile(sessionId: string, userId: string): Promise { + const res = await this.pgClient.query( + `SELECT p.* FROM vibe_session_profiles p + JOIN vibe_sessions s ON s.id = p.session_id + WHERE p.session_id = $1 AND s.user_id = $2`, + [sessionId, userId], + ); + return (res.rows[0] as VibeSessionProfile | undefined) ?? null; + } + + /** Replace only the coarse, sanitised context attached to an active session. + * The immutable context_changed event remains the audit trail. */ + async updateVibeSessionContext(sessionId: string, userId: string, context: Record): Promise { + const canonicalContext = normalizeVibeContext(context); + await this.withTransaction(async client => { + const updated = await client.query( + `UPDATE vibe_sessions SET context = $3::jsonb, last_event_at = NOW() + WHERE id = $1 AND user_id = $2 AND status = 'active' + RETURNING id`, + [sessionId, userId, JSON.stringify(canonicalContext)], + ); + if (!updated.rows[0]) throw new Error('Vibe session was not found or is not owned by this user'); + await client.query( + `UPDATE session_state + SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), + state_vector = state_vector || jsonb_build_object('context', $3::jsonb), + last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [sessionId, userId, JSON.stringify(canonicalContext)], + ); + }); + } + + /** + * Project unknown-track feedback into the session exploration controls. + * This deliberately runs behind its own projection marker: the immutable + * event has already committed, so retries after a transient failure are + * safe and converge on one evidence row and one coefficient adjustment. + */ + async projectVibeSessionFeedback(event: VibeEvent): Promise { + if (!event.track_id || !['completed', 'skipped', 'kept'].includes(event.type)) return; + const trackId = event.track_id; + await this.withTransaction(async client => { + // Old active/resumable sessions predate vibe_session_profiles. Create a + // neutral profile before claiming the exactly-once marker: otherwise the + // marker could permanently consume a feedback event without adapting its + // session. Existing goals are deliberately never overwritten here. + await client.query( + `INSERT INTO vibe_session_profiles (session_id, user_id) + VALUES ($1, $2) + ON CONFLICT (session_id) DO NOTHING`, + [event.session_id, event.user_id], + ); + const marker = await client.query( + `INSERT INTO vibe_session_feedback_projections (event_id) + VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, + [event.id], + ); + if (!marker.rows[0]) return; + + // This query occurs before a completed event's play_history projection + // can be considered. Favourites and prior evidence count as familiarity + // too, avoiding a false “new discovery” on a locally known track. + const familiarity = await client.query( + `SELECT ( + EXISTS (SELECT 1 FROM play_history WHERE user_id = $1 AND track_id = $2 AND completed = true AND played_at < $3::timestamptz) + OR EXISTS (SELECT 1 FROM favorites WHERE user_id = $1 AND track_id = $2) + OR EXISTS (SELECT 1 FROM evidence WHERE user_id = $1 AND entity_type = 'track' AND entity_id = $2 AND created_at < $3::timestamptz) + ) AS familiar`, + [event.user_id, trackId, event.occurred_at], + ); + const familiar = Boolean(familiarity.rows[0]?.familiar); + + const delta = event.type === 'skipped' ? -0.08 : event.type === 'completed' ? 0.06 : 0.03; + const signal = event.type === 'skipped' ? 'skip_quick' : 'play_of_never_seen'; + const weight = event.type === 'skipped' ? -0.05 : delta; + if (!familiar) { + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: trackId, + signal, + profile: event.type === 'skipped' ? 'negative' : 'discovery', + weight, + context: { vibe_event_id: event.id, session_id: event.session_id, unfamiliar: true }, + }, client); + } + + const profile = await client.query( + `UPDATE vibe_session_profiles + SET exploration_coefficient = GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)), + discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)) * 0.65))), + goals = CASE + WHEN goals->>'type' = 'familiar' AND $4 AND $5 + THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1)))) + WHEN goals->>'type' IN ('discovery', 'surprise', 'artist_introduction') AND NOT $4 AND $3 > 0 + THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1)))) + ELSE goals END, + updated_at = NOW() + WHERE session_id = $1 AND user_id = $2 + RETURNING exploration_coefficient, discovery_radius, goals`, + [event.session_id, event.user_id, delta, familiar, event.type === 'completed' || event.type === 'kept'], + ); + const row = profile.rows[0] as Pick | undefined; + if (row) { + await client.query( + `UPDATE session_state + SET state_vector = state_vector || jsonb_build_object( + 'explorationCoefficient', $3::real, + 'discoveryRadius', $4::real, + 'sessionGoal', $5::jsonb + ), last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [event.session_id, event.user_id, row.exploration_coefficient, row.discovery_radius, JSON.stringify(row.goals)], + ); + } + }); + } + /** * End (or expire/replace) a session without changing its original end time * when a client retries the same request. @@ -1667,6 +1825,10 @@ export class DbService { durationMs?: number | null; payload?: Record; }): Promise { + // This service is also called by jobs and tests which bypass the HTTP + // route. Preserve the context privacy boundary at the final point before + // an immutable ledger write. + const payload = normalizeVibeEventPayload(params.type, params.payload); return this.withTransaction(async (client) => { // A session-row lock serializes both event writes and terminal state // transitions. In particular, it avoids the READ COMMITTED CTE snapshot @@ -1718,7 +1880,7 @@ export class DbService { occurredAt, params.positionMs ?? null, params.durationMs ?? null, - JSON.stringify(params.payload ?? {}), + JSON.stringify(payload ?? {}), ] ); const event = insertRes.rows[0] as VibeEvent | undefined; @@ -1727,6 +1889,7 @@ export class DbService { } await this.projectVibeFeedback(event, client); + await this.projectVibeContextChanged(event, client); await client.query( `UPDATE vibe_sessions @@ -1738,6 +1901,30 @@ export class DbService { }); } + /** Apply the context projection in the same transaction as its *inserted* + * ledger event. A client-event retry returns before this method, so its body + * can never overwrite session state with a different context. */ + private async projectVibeContextChanged(event: VibeEvent, client: PoolClient): Promise { + if (event.type !== 'context_changed') return; + const context = event.payload?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) return; + const canonicalContext = context as Record; + await client.query( + `UPDATE vibe_sessions + SET context = $3::jsonb + WHERE id = $1 AND user_id = $2 AND status = 'active'`, + [event.session_id, event.user_id, JSON.stringify(canonicalContext)], + ); + await client.query( + `UPDATE session_state + SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), + state_vector = state_vector || jsonb_build_object('context', $3::jsonb), + last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [event.session_id, event.user_id, JSON.stringify(canonicalContext)], + ); + } + /** * Materialize Vibe feedback into the listener inputs used by the incumbent * director. The projection marker and every write share the event's @@ -1899,6 +2086,32 @@ export class DbService { feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null, })], ); + // Store a compact session shape rather than a replayable queue. It is + // overwritten on each revision so a recently adapted session represents + // its current direction when tomorrow's session asks for freshness. + await client.query( + `WITH selected AS ( + SELECT i.track_id, i.candidate_source + FROM vibe_plan_items i WHERE i.plan_version_id = $1 + ), artists AS ( + SELECT DISTINCT ta.artist_id::text AS value FROM selected s + JOIN track_artists_v2 ta ON ta.track_id = s.track_id AND ta.role = 'main' + ), genres AS ( + SELECT DISTINCT tg.genre_id::text AS value FROM selected s + JOIN track_genre tg ON tg.track_id = s.track_id + ), sources AS ( + SELECT DISTINCT candidate_source AS value FROM selected + ) + UPDATE vibe_session_profiles + SET fingerprint = jsonb_build_object( + 'artists', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM artists), '[]'::jsonb), + 'genres', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM genres), '[]'::jsonb), + 'sources', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM sources), '[]'::jsonb), + 'context', (SELECT context FROM vibe_sessions WHERE id = $2) + ), updated_at = NOW() + WHERE session_id = $2 AND user_id = $3`, + [planVersion.id, params.sessionId, params.userId], + ); await client.query( `UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [params.sessionId], diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 8612b5f..6b937fd 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -49,6 +49,10 @@ export interface GeneratorContext { lastGenreIds: string[]; context: string | null; noveltyHunger: number; + /** Session-local exploration controls; they never overwrite durable taste. */ + explorationCoefficient?: number; + discoveryRadius?: number; + sessionGoal?: { type: string; target: number; progress: number }; sessionAgeMin: number; }; } diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index e04c621..24f107d 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -91,6 +91,33 @@ export interface PlanBuildOptions { retainedPlan?: Candidate[]; } +export interface SessionFingerprint { + artists?: string[]; + genres?: string[]; + sources?: string[]; + context?: Record; +} + +/** A compact Jaccard penalty. It is deliberately capped: last night's shape + * should make an alternative more attractive, not make good music ineligible. */ +export function sessionSimilarityPenalty( + metadata: CandidateConstraintMetadata | undefined, + generatorId: string, + fingerprints: SessionFingerprint[], +): number { + if (!metadata || fingerprints.length === 0) return 0; + let strongest = 0; + for (const fingerprint of fingerprints) { + let matches = 0; + let known = 0; + if (metadata.artistId && fingerprint.artists?.length) { known++; if (fingerprint.artists.includes(metadata.artistId)) matches++; } + if (metadata.genreId && fingerprint.genres?.length) { known++; if (fingerprint.genres.includes(metadata.genreId)) matches++; } + if (fingerprint.sources?.length) { known++; if (fingerprint.sources.includes(generatorId)) matches++; } + if (known > 0) strongest = Math.max(strongest, matches / known); + } + return Math.min(0.18, strongest * 0.18); +} + export interface ArcRange { min?: number; max?: number; @@ -729,6 +756,9 @@ export class SessionDirector { lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [], context: row.context, noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3, + explorationCoefficient: (row.state_vector?.explorationCoefficient as number) ?? 0.3, + discoveryRadius: (row.state_vector?.discoveryRadius as number) ?? 0.38, + sessionGoal: row.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined, sessionAgeMin: row.started_at ? (Date.now() - new Date(row.started_at).getTime()) / 60000 : 0, @@ -745,6 +775,9 @@ export class SessionDirector { lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [], context: latest.context, noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3, + explorationCoefficient: (latest.state_vector?.explorationCoefficient as number) ?? 0.3, + discoveryRadius: (latest.state_vector?.discoveryRadius as number) ?? 0.38, + sessionGoal: latest.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined, sessionAgeMin: latest.started_at ? (Date.now() - new Date(latest.started_at).getTime()) / 60000 : 0, @@ -782,6 +815,9 @@ export class SessionDirector { if (preferredEnergy !== null && (energyBelief.rows[0]?.value ?? 0) > 0) { energy = energy * 0.65 + preferredEnergy * 0.35; } + // Activity/device/time supply a boot prior only. Once playback exists its + // contribution stays small so context never masquerades as feedback. + if (savedState) energy = energy * 0.85 + savedState.energy * 0.15; // Read novelty hunger from discovery profile const noveltyRes = await this.db.pgClient.query( @@ -790,7 +826,12 @@ export class SessionDirector { LIMIT 1`, [userId] ); - const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3; + const durableNovelty = (noveltyRes.rows[0]?.value as number) ?? 0.3; + const explorationCoefficient = Math.max(0, Math.min(1, savedState?.explorationCoefficient ?? 0.3)); + const discoveryRadius = Math.max(0.15, Math.min(0.9, savedState?.discoveryRadius ?? 0.38)); + // A contextual/session feedback signal is only a minority of the input; + // long-term discovery belief still stabilises the stream across resumes. + const noveltyHunger = durableNovelty * 0.65 + explorationCoefficient * 0.35; // Last distinct artist IDs from recent completed plays. // Use a subquery to order first, then DISTINCT — avoids PG's rule that @@ -832,6 +873,9 @@ export class SessionDirector { lastGenreIds, context: savedState?.context ?? null, noveltyHunger, + explorationCoefficient, + discoveryRadius, + sessionGoal: savedState?.sessionGoal, sessionAgeMin: age, }; } @@ -1042,6 +1086,11 @@ export class SessionDirector { // D.4 — Arc selection // --------------------------------------------------------------- pickArc(state: GeneratorContext['state']): string { + const goal = state.sessionGoal; + if (goal && goal.progress < goal.target) { + if (goal.type === 'familiar') return 'comfort'; + if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery'; + } if (state.energy < 0.3) return 'late-night'; if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery'; if (state.energy > 0.6) return 'energetic'; @@ -1505,6 +1554,7 @@ export class SessionDirector { state: GeneratorContext['state'], repetitionState: RepetitionState, userId = '', + recentSessionFingerprints: SessionFingerprint[] = [], ): Promise { if (candidates.length === 0) return []; @@ -1551,11 +1601,16 @@ export class SessionDirector { const wouldRepeat = repetitionState.recentTrackIds.has(c.trackId) || (!!artistId && repetitionState.recentArtistIds.has(artistId)); + const novelty = candidateNovelty(c, item); + const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38)); + const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints); let score = W_ENJOY * c.relevance - W_FATIGUE * avgFatigue + W_DIVERSITY * diversityBonus - + W_ENTROPY * entropyBonus; + + W_ENTROPY * entropyBonus + + 0.08 * explorationFit + - sessionSimilarity; if (wouldRepeat) { score *= 0.1; @@ -1597,6 +1652,9 @@ export class SessionDirector { lastArtistIds: state.lastArtistIds, lastGenreIds: state.lastGenreIds, noveltyHunger: state.noveltyHunger, + explorationCoefficient: state.explorationCoefficient ?? 0.3, + discoveryRadius: state.discoveryRadius ?? 0.38, + sessionGoal: state.sessionGoal ?? { type: 'discovery', target: 1, progress: 0 }, }), ] ); @@ -1659,6 +1717,10 @@ export class SessionDirector { // disliked, or otherwise exposed track can never leak into a replacement // revision for this session. const durableSessionTrackIds = await this.db.getVibeSessionTrackIds(sessionId, userId); + const recentFingerprintReader = (this.db as Partial).getRecentVibeSessionFingerprints; + const recentSessionFingerprints = recentFingerprintReader + ? await recentFingerprintReader.call(this.db, userId, sessionId) as SessionFingerprint[] + : []; // Do not let abundant track-level beliefs crowd out the artist/genre // affinities required by the discovery generators. const beliefGroups = await Promise.all([ @@ -1795,7 +1857,7 @@ export class SessionDirector { return []; } const ranked = await this.rankCandidates( - eligibleCandidates, fatigue, budgets, state, repetitionState, userId + eligibleCandidates, fatigue, budgets, state, repetitionState, userId, recentSessionFingerprints ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 581035d..87a890f 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js'; import { DbService } from './db.service.js'; import { ALL_GENERATORS } from './generators.service.js'; @@ -425,6 +425,24 @@ describe('SessionDirector', () => { }); + describe('recent session fingerprint penalty', () => { + it('softly penalizes a repeated session shape without excluding it', () => { + const repeated = sessionSimilarityPenalty( + { artistId: 'artist-1', genreId: 'genre-1' }, + 'comfort', + [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }], + ); + const newShape = sessionSimilarityPenalty( + { artistId: 'artist-2', genreId: 'genre-2' }, + 'discovery', + [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }], + ); + expect(repeated).toBeGreaterThan(0); + expect(repeated).toBeLessThanOrEqual(0.18); + expect(newShape).toBe(0); + }); + }); + describe('sequence constraints', () => { const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' })); const budgets = [ diff --git a/backend/src/services/vibe-context.service.test.ts b/backend/src/services/vibe-context.service.test.ts new file mode 100644 index 0000000..9c1bc5d --- /dev/null +++ b/backend/src/services/vibe-context.service.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { initialVibeState, normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; + +describe('Vibe context', () => { + it('keeps only coarse structured values and derives time on the server', () => { + const context = normalizeVibeContext({ + timeZone: 'UTC', activity: 'workout', device: 'headphones', + exactCoordinates: '53.2,50.1', localHour: 3, + }, new Date('2026-08-02T19:00:00.000Z')); + + expect(context).toMatchObject({ localHour: 19, weekday: 0, dayKind: 'weekend', activity: 'workout' }); + expect(context).not.toHaveProperty('exactCoordinates'); + expect(context).not.toHaveProperty('localHour', 3); + }); + + it('uses context only as a bounded initial prior and gives focus a comfort goal', () => { + const state = initialVibeState(normalizeVibeContext({ activity: 'focus' }, new Date('2026-08-03T12:00:00.000Z'))); + expect(state.energy).toBeGreaterThan(0); + expect(state.energy).toBeLessThan(1); + expect(state.sessionGoal).toEqual({ type: 'familiar', target: 1, progress: 0 }); + }); + + it('persists only canonical context for context-change events', () => { + const payload = normalizeVibeEventPayload('context_changed', { + context: { activity: 'walking', exactCoordinates: '53.2,50.1', adId: 'do-not-store' }, + rawBrowserTelemetry: { battery: 4 }, + }); + + expect(payload).toEqual({ + context: expect.objectContaining({ activity: 'walking' }), + }); + expect(payload).not.toHaveProperty('rawBrowserTelemetry'); + expect((payload?.context as Record)).not.toHaveProperty('exactCoordinates'); + expect((payload?.context as Record)).not.toHaveProperty('adId'); + }); +}); diff --git a/backend/src/services/vibe-context.service.ts b/backend/src/services/vibe-context.service.ts new file mode 100644 index 0000000..4c3b497 --- /dev/null +++ b/backend/src/services/vibe-context.service.ts @@ -0,0 +1,120 @@ +/** + * Coarse, opt-in context accepted by the durable Vibe API. It deliberately + * has no precise location, identifiers, or browser telemetry: clients can + * supply a hint, but the server owns the time fields and can ignore all of it. + */ +export const VIBE_CONTEXT_VALUES = { + device: ['desktop', 'phone', 'speaker', 'car', 'headphones'] as const, + activity: ['focus', 'relax', 'walking', 'workout', 'social', 'unknown'] as const, + locationCategory: ['home', 'work', 'gym', 'travel', 'unknown'] as const, + weather: ['clear', 'rain', 'snow', 'hot', 'cold', 'unknown'] as const, + source: ['current_track', 'artist', 'genre', 'surprise', 'resume'] as const, +}; + +export interface VibeContext { + timeZone?: string; + localHour?: number; + weekday?: number; + dayKind?: 'weekday' | 'weekend' | 'holiday'; + device?: (typeof VIBE_CONTEXT_VALUES.device)[number]; + activity?: (typeof VIBE_CONTEXT_VALUES.activity)[number]; + locationCategory?: (typeof VIBE_CONTEXT_VALUES.locationCategory)[number]; + weather?: (typeof VIBE_CONTEXT_VALUES.weather)[number]; + source?: (typeof VIBE_CONTEXT_VALUES.source)[number]; +} + +export interface InitialVibeState { + contextLabel: string | undefined; + energy: number; + noveltyHunger: number; + explorationCoefficient: number; + discoveryRadius: number; + sessionGoal: { type: 'surprise' | 'familiar' | 'discovery' | 'artist_introduction'; target: number; progress: number }; +} + +const hasValue = (values: T, value: unknown): value is T[number] => + typeof value === 'string' && (values as readonly string[]).includes(value); + +function serverTime(timeZone?: string, now = new Date()): Pick { + // Intl rejects bad IANA names. Falling back to the server clock is safe and + // still makes time a weak prior rather than client-controlled fact. + let zone: string | undefined; + try { + if (timeZone) new Intl.DateTimeFormat('en-US', { timeZone }).format(now); + zone = timeZone; + } catch { /* server-local fallback */ } + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, hour: 'numeric', weekday: 'short', hourCycle: 'h23', + }).formatToParts(now); + const hour = Number(parts.find(part => part.type === 'hour')?.value ?? now.getHours()); + const weekdayName = parts.find(part => part.type === 'weekday')?.value; + const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName ?? ''); + return { + ...(zone ? { timeZone: zone } : {}), + localHour: Number.isInteger(hour) ? hour : now.getHours(), + weekday: weekday >= 0 ? weekday : now.getDay(), + dayKind: ([0, 6].includes(weekday >= 0 ? weekday : now.getDay()) ? 'weekend' : 'weekday'), + }; +} + +/** Remove unknown fields and derive time server-side. This preserves old + * clients that send an empty object while preventing opaque context blobs from + * becoming a permanent behavioural profile. */ +export function normalizeVibeContext(input: Record = {}, now = new Date()): VibeContext { + const time = serverTime(typeof input.timeZone === 'string' ? input.timeZone : undefined, now); + return { + ...time, + ...(hasValue(VIBE_CONTEXT_VALUES.device, input.device) ? { device: input.device } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.activity, input.activity) ? { activity: input.activity } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.locationCategory, input.locationCategory) ? { locationCategory: input.locationCategory } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.weather, input.weather) ? { weather: input.weather } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.source, input.source) ? { source: input.source } : {}), + }; +} + +/** + * Context changes are the only event payload with a structured, durable + * context body. Keep their ledger representation intentionally tiny: callers + * cannot smuggle precise location or arbitrary browser telemetry into an + * immutable event by adding sibling fields or unknown context keys. + */ +export function normalizeVibeEventPayload( + type: string, + payload?: Record, +): Record | undefined { + if (type !== 'context_changed') return payload; + const context = payload?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) return {}; + return { context: normalizeVibeContext(context as Record) }; +} + +/** Context is intentionally a gentle prior. It can nudge the initial arc but + * never overrides observed playback behaviour. */ +export function initialVibeState(context: VibeContext): InitialVibeState { + const activityEnergy: Record = { + focus: 0.42, relax: 0.34, walking: 0.58, workout: 0.72, social: 0.62, unknown: 0.5, + }; + const hour = context.localHour ?? 12; + const hourEnergy = hour < 6 ? 0.32 : hour < 10 ? 0.46 : hour >= 22 ? 0.38 : 0.52; + const activity = context.activity ?? 'unknown'; + const energy = Math.max(0, Math.min(1, activityEnergy[activity] * 0.7 + hourEnergy * 0.3)); + const goal = activity === 'focus' || activity === 'relax' + ? 'familiar' + : activity === 'workout' || activity === 'walking' ? 'surprise' : 'discovery'; + return { + contextLabel: context.activity ?? context.device, + energy, + noveltyHunger: 0.3, + explorationCoefficient: 0.3, + discoveryRadius: 0.38, + sessionGoal: { type: goal, target: 1, progress: 0 }, + }; +} + +export function isValidVibeContext(input: Record): boolean { + const scalar = (key: keyof typeof VIBE_CONTEXT_VALUES) => input[key] === undefined + || hasValue(VIBE_CONTEXT_VALUES[key], input[key]); + return (input.timeZone === undefined || typeof input.timeZone === 'string') + && scalar('device') && scalar('activity') && scalar('locationCategory') + && scalar('weather') && scalar('source'); +} diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index ecf149e..3c771d8 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -154,6 +154,44 @@ describe('VibeSessionCoordinator', () => { expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); }); + it('normalizes context before the immutable event write', async () => { + const { db, coordinator } = setup(); + (db.recordVibeEvent as any).mockResolvedValueOnce({ + event: { id: 'event-1', type: 'context_changed', payload: {} }, inserted: true, + }); + + await coordinator.appendEvent('user-1', SESSION_ID, { + type: 'context_changed', + payload: { + context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, + rawBrowserTelemetry: { battery: 4 }, + }, + }); + + expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ + payload: { + context: expect.objectContaining({ activity: 'walking' }), + }, + })); + const payload = (db.recordVibeEvent as any).mock.calls[0][0].payload; + expect(payload).not.toHaveProperty('rawBrowserTelemetry'); + expect(payload.context).not.toHaveProperty('exactCoordinates'); + }); + + it('creates the durable profile from the initial context goal', async () => { + const { db, coordinator } = setup(); + + await coordinator.start('user-1', { context: { activity: 'focus' } }); + + expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ + profile: expect.objectContaining({ + goals: { type: 'familiar', target: 1, progress: 0 }, + explorationCoefficient: 0.3, + discoveryRadius: 0.38, + }), + })); + }); + it('passes the durable unserved callback tail into the retention-aware replan', async () => { const { db, director, coordinator } = setup(); (db.getVibePlan as any).mockResolvedValue({ diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index 39779d7..ad0e0db 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -1,6 +1,12 @@ import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; import { Candidate } from './generators.service.js'; +import { + initialVibeState, + normalizeVibeContext, + normalizeVibeEventPayload, + VibeContext, +} from './vibe-context.service.js'; /** * This is deliberately a narrow bridge between the durable Vibe ledger and @@ -21,7 +27,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number]; export interface StartVibeSessionInput { seedTrackId?: string; - context?: Record; + context?: VibeContext | Record; intent?: string; policyVersion?: string; resumeSessionId?: string; @@ -73,13 +79,19 @@ export class VibeSessionCoordinator { async start(userId: string, input: StartVibeSessionInput): Promise { if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); - const context = input.context ?? {}; + const context = normalizeVibeContext({ ...(input.context ?? {}) }); + const initialState = initialVibeState(context); const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION; const session = await this.db.createVibeSession({ userId, policyVersion, seedTrackId: input.seedTrackId ?? null, - context, + context: { ...context }, + profile: { + goals: initialState.sessionGoal, + explorationCoefficient: initialState.explorationCoefficient, + discoveryRadius: initialState.discoveryRadius, + }, }); // session_state is a derived cache used by the current director. Give it @@ -87,8 +99,15 @@ export class VibeSessionCoordinator { // session while the durable tables remain the source of truth. await this.db.createSessionState( userId, - typeof context.activity === 'string' ? context.activity : input.intent, - { energy: 0.5, noveltyHunger: 0.3 }, + initialState.contextLabel ?? input.intent, + { + energy: initialState.energy, + noveltyHunger: initialState.noveltyHunger, + explorationCoefficient: initialState.explorationCoefficient, + discoveryRadius: initialState.discoveryRadius, + sessionGoal: initialState.sessionGoal, + context, + }, session.id, ); await this.db.recordVibeEvent({ @@ -180,6 +199,11 @@ export class VibeSessionCoordinator { input: AppendVibeEventInput, ): Promise { try { + // Do this before the ledger write, rather than after it, because Vibe + // events are immutable. The DB repeats this boundary for non-HTTP + // callers; keeping it here also makes coordinator callers see exactly + // what will be persisted. + const payload = normalizeVibeEventPayload(input.type, input.payload); const result = await this.db.recordVibeEvent({ sessionId, userId, @@ -189,8 +213,13 @@ export class VibeSessionCoordinator { occurredAt: input.occurredAt, positionMs: input.positionMs, durationMs: input.durationMs, - payload: input.payload, + payload, }); + // The ledger write is authoritative; this idempotent projection updates + // exploration only after the exact event exists. Keep the compatibility + // guard for old coordinator test doubles during the migration. + const projectSessionFeedback = (this.db as Partial).projectVibeSessionFeedback; + if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event); if (!isMaterialFeedback(input.type)) { const response = await this.getPlan(userId, sessionId); return { ...response, event: result.event, idempotent: !result.inserted }; From 9eba247a58de02973408617a943a26d879a36aeb Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 3 Aug 2026 12:44:08 +0400 Subject: [PATCH 8/8] refactor(vibe): simplify durable session flow --- README.md | 6 +- backend/src/app.ts | 7 - .../src/routes/vibe-sessions.routes.test.ts | 48 ++-- backend/src/routes/vibe-sessions.routes.ts | 256 ++++++++---------- backend/src/services/db.service.test.ts | 79 ++---- backend/src/services/db.service.ts | 64 +---- .../src/services/session-director.service.ts | 2 +- .../src/services/vibe-context.service.test.ts | 36 --- backend/src/services/vibe-context.service.ts | 120 -------- .../vibe-session-coordinator.service.test.ts | 38 +-- .../vibe-session-coordinator.service.ts | 37 +-- docker-compose.yml | 4 - frontend/src/services/vibeService.test.ts | 4 +- frontend/src/services/vibeService.ts | 4 +- frontend/src/services/vibeSession.ts | 2 +- frontend/src/types.ts | 4 +- 16 files changed, 191 insertions(+), 520 deletions(-) delete mode 100644 backend/src/services/vibe-context.service.test.ts delete mode 100644 backend/src/services/vibe-context.service.ts diff --git a/README.md b/README.md index ec7d06a..e5ec4d8 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ A high-performance, distributed music orchestration and recommendation platform. ### Running Locally -Set `MUZICK_VIBE_USER_ID` in `.env` to the UUID of the local Muzick user before -using Vibe. Durable Vibe session routes intentionally reject client-supplied -identities, so this is the trusted single-user binding for a self-hosted stack. +Vibe uses the same per-user identity convention as the rest of the API: +`x-user-id` when supplied, otherwise the local default user. Each user's Vibe +session and listening history are isolated from other users. ```bash docker-compose up -d diff --git a/backend/src/app.ts b/backend/src/app.ts index 42ecdb0..594abd4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,7 +14,6 @@ import quarantineRoutes from './routes/quarantine.routes.js'; import settingsRoutes from './routes/settings.routes.js'; import graphRoutes from './routes/graph.routes.js'; import { SessionDirector } from './services/session-director.service.js'; -import v2Routes from './routes/v2.routes.js'; import vibeSessionsRoutes from './routes/vibe-sessions.routes.js'; import discoveryRoutes from './routes/discovery.routes.js'; import imagesRoutes from './routes/images.routes.js'; @@ -161,16 +160,10 @@ export async function buildApp(config: AppConfig) { const sessionDirector = new SessionDirector(dbService); const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector); - // Durable sessions intentionally do not trust x-user-id. A self-hosted - // deployment may configure one owner through MUZICK_VIBE_USER_ID today; - // an authenticated deployment can replace this resolver at registration. - const vibeOwnerId = process.env.MUZICK_VIBE_USER_ID?.trim() || null; - fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); fastify.register(vibeSessionsRoutes, { prefix: '/api', coordinator: vibeSessionCoordinator, - identityResolver: () => vibeOwnerId, }); fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService }); // ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed — diff --git a/backend/src/routes/vibe-sessions.routes.test.ts b/backend/src/routes/vibe-sessions.routes.test.ts index f739f4d..ba3c0b4 100644 --- a/backend/src/routes/vibe-sessions.routes.test.ts +++ b/backend/src/routes/vibe-sessions.routes.test.ts @@ -15,7 +15,7 @@ function response() { }; } -async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => USER_ID) { +async function appWithCoordinator(identityResolver?: VibeIdentityResolver) { const coordinator = { start: vi.fn().mockResolvedValue(response()), getPlan: vi.fn().mockResolvedValue(response()), @@ -25,36 +25,36 @@ async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => advancePastUnplayable: vi.fn().mockResolvedValue(response()), } as any; const app = Fastify(); - await app.register(vibeSessionsRoutes, { coordinator, identityResolver }); + await app.register(vibeSessionsRoutes, { coordinator, ...(identityResolver ? { identityResolver } : {}) }); await app.ready(); return { app, coordinator }; } describe('durable Vibe session routes', () => { - it('uses the trusted identity resolver and never accepts a spoofed x-user-id header', async () => { + it('uses the caller identity so concurrent listeners receive separate sessions', async () => { const { app, coordinator } = await appWithCoordinator(); - const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} }); + const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {} }); expect(result.statusCode).toBe(201); expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object)); await app.close(); }); - it('rejects requests when no trusted identity is configured instead of defaulting a shared user', async () => { - const { app, coordinator } = await appWithCoordinator(() => null); + it('uses the existing application default when no user header is provided', async () => { + const { app, coordinator } = await appWithCoordinator(); const result = await app.inject({ - method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {}, + method: 'POST', url: '/v2/vibe/sessions', payload: {}, }); - expect(result.statusCode).toBe(401); - expect(coordinator.start).not.toHaveBeenCalled(); + expect(result.statusCode).toBe(201); + expect(coordinator.start).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000', expect.any(Object)); await app.close(); }); it('creates a session and validates event payloads before touching the coordinator', async () => { - const { app, coordinator } = await appWithCoordinator(); + const { app, coordinator } = await appWithCoordinator(() => USER_ID); const created = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' }, - payload: { seedTrackId: TRACK_ID, context: { activity: 'focus' }, policyVersion: 'test-policy' }, + payload: { seedTrackId: TRACK_ID }, }); const invalidEvent = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' }, @@ -62,13 +62,13 @@ describe('durable Vibe session routes', () => { }); expect(created.statusCode).toBe(201); - expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ policyVersion: 'test-policy' })); + expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ seedTrackId: TRACK_ID })); expect(invalidEvent.statusCode).toBe(400); expect(coordinator.appendEvent).not.toHaveBeenCalled(); await app.close(); }); - it('reserves track_served for the authoritative /next operation', async () => { + it('rejects server-only event types from the client event ledger', async () => { const { app, coordinator } = await appWithCoordinator(); const result = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, @@ -80,7 +80,7 @@ describe('durable Vibe session routes', () => { }); expect(result.statusCode).toBe(400); - expect(result.json()).toEqual({ error: 'track_served is reserved for the server /next operation' }); + expect(result.json()).toEqual({ error: 'type must be a supported client Vibe event type' }); expect(coordinator.appendEvent).not.toHaveBeenCalled(); await app.close(); }); @@ -102,7 +102,7 @@ describe('durable Vibe session routes', () => { }); it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => { - const { app, coordinator } = await appWithCoordinator(); + const { app, coordinator } = await appWithCoordinator(() => USER_ID); const plan = await app.inject({ method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' }, }); @@ -118,8 +118,8 @@ describe('durable Vibe session routes', () => { await app.close(); }); - it('validates occurredAt and exposes owned resume and next operations', async () => { - const { app, coordinator } = await appWithCoordinator(); + it('validates occurredAt and exposes owned resume and advance operations', async () => { + const { app, coordinator } = await appWithCoordinator(() => USER_ID); const resume = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID }, }); @@ -127,7 +127,7 @@ describe('durable Vibe session routes', () => { method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, payload: { type: 'completed', occurredAt: 'not-a-date' }, }); - const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next` }); + const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance` }); expect(resume.statusCode).toBe(201); expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID })); expect(badTime.statusCode).toBe(400); @@ -136,13 +136,13 @@ describe('durable Vibe session routes', () => { await app.close(); }); - it('passes a version-aware next request through and rejects an invalid expected version', async () => { - const { app, coordinator } = await appWithCoordinator(); + it('passes a version-aware advance request through and rejects an invalid expected version', async () => { + const { app, coordinator } = await appWithCoordinator(() => USER_ID); const valid = await app.inject({ - method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 2 }, + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2 }, }); const invalid = await app.inject({ - method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 0 }, + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 0 }, }); expect(valid.statusCode).toBe(200); @@ -153,9 +153,9 @@ describe('durable Vibe session routes', () => { }); it('uses the explicit versioned advancement protocol for a served unplayable item', async () => { - const { app, coordinator } = await appWithCoordinator(); + const { app, coordinator } = await appWithCoordinator(() => USER_ID); const result = await app.inject({ - method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, + method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2, unplayable: { diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index 8e0eaa7..f5bfef7 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -6,89 +6,125 @@ import { VibeSessionNotFoundError, VibePlanNotFoundError, } from '../services/vibe-session-coordinator.service.js'; -import { isValidVibeContext } from '../services/vibe-context.service.js'; -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i; +const CLIENT_VIBE_EVENT_TYPES = VIBE_EVENT_TYPES.filter((type) => ![ + 'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served', +].includes(type)); -/** Identity must come from authenticated server configuration/middleware, never a client header. */ +type Reply = { code: (statusCode: number) => { send: (payload: unknown) => unknown } }; +type Body = Record; + +/** This mirrors the rest of the application until authentication owns identity. */ export type VibeIdentityResolver = (request: FastifyRequest) => string | null; -function isObject(value: unknown): value is Record { +function isObject(value: unknown): value is Body { return typeof value === 'object' && value !== null && !Array.isArray(value); } function validUuid(value: unknown): value is string { - return typeof value === 'string' && UUID_RE.test(value); + return value === DEFAULT_USER_ID || (typeof value === 'string' && UUID_RE.test(value)); +} + +function requestUser(request: FastifyRequest, resolveIdentity?: VibeIdentityResolver): string | null { + const resolved = resolveIdentity?.(request); + if (resolved !== undefined) return validUuid(resolved) ? resolved : null; + const header = request.headers['x-user-id']; + const userId = typeof header === 'string' && header ? header : DEFAULT_USER_ID; + return validUuid(userId) ? userId : null; } function validOccurredAt(value: unknown): value is string { - // Require an actual offset-bearing timestamp, rather than Date.parse's - // permissive inputs such as "2026" or locale-dependent strings. - return typeof value === 'string' - && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) - && !Number.isNaN(Date.parse(value)); + return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value)); +} + +function validationError(reply: Reply, message: string) { + return reply.code(400).send({ error: message }); +} + +function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null { + const { sessionId } = request.params as { sessionId: string }; + return validUuid(sessionId) ? sessionId : (validationError(reply, 'sessionId must be a UUID'), null); +} + +function parseStart(body: unknown): + | { seedTrackId?: string; resumeSessionId?: string } + | { error: string } { + const input = isObject(body) ? body : {}; + if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' }; + if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' }; + if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' }; + return { seedTrackId: input.seedTrackId as string | undefined, resumeSessionId: input.resumeSessionId as string | undefined }; +} + +function parseEvent(body: unknown): + | { type: typeof CLIENT_VIBE_EVENT_TYPES[number]; eventId?: string; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Body } + | { error: string } { + if (!isObject(body)) return { error: 'event body must be an object' }; + if (!CLIENT_VIBE_EVENT_TYPES.includes(body.type as typeof CLIENT_VIBE_EVENT_TYPES[number])) return { error: 'type must be a supported client Vibe event type' }; + if (body.eventId !== undefined && !validUuid(body.eventId)) return { error: 'eventId must be a UUID' }; + if (body.trackId !== undefined && !validUuid(body.trackId)) return { error: 'trackId must be a UUID' }; + if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) return { error: 'occurredAt must be an ISO-8601 timestamp' }; + if (body.positionMs !== undefined && (typeof body.positionMs !== 'number' || !Number.isInteger(body.positionMs) || body.positionMs < 0)) return { error: 'positionMs must be a non-negative integer' }; + if (body.durationMs !== undefined && (typeof body.durationMs !== 'number' || !Number.isInteger(body.durationMs) || body.durationMs < 0)) return { error: 'durationMs must be a non-negative integer' }; + if (body.payload !== undefined && !isObject(body.payload)) return { error: 'payload must be an object' }; + return { + type: body.type as typeof CLIENT_VIBE_EVENT_TYPES[number], + eventId: body.eventId as string | undefined, + trackId: body.trackId as string | undefined, + occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string), + positionMs: body.positionMs as number | undefined, + durationMs: body.durationMs as number | undefined, + payload: body.payload as Body | undefined, + }; +} + +function parseAdvance(body: unknown): + | { expectedPlanVersion?: number; unplayable?: { eventId: string; planVersionId: string; ordinal: number; trackId: string } } + | { error: string } { + const input = isObject(body) ? body : {}; + const version = input.expectedPlanVersion; + if (version !== undefined && (typeof version !== 'number' || !Number.isInteger(version) || version < 1)) return { error: 'expectedPlanVersion must be a positive integer' }; + if (input.unplayable === undefined) return { expectedPlanVersion: version as number | undefined }; + if (!isObject(input.unplayable)) return { error: 'unplayable must be an object' }; + const unplayable = input.unplayable; + if (version === undefined) return { error: 'expectedPlanVersion is required when advancing an unplayable item' }; + if (!validUuid(unplayable.eventId) || !validUuid(unplayable.planVersionId) || !validUuid(unplayable.trackId) || typeof unplayable.ordinal !== 'number' || !Number.isInteger(unplayable.ordinal) || unplayable.ordinal < 0) { + return { error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' }; + } + return { expectedPlanVersion: version as number, unplayable: unplayable as { eventId: string; planVersionId: string; ordinal: number; trackId: string } }; } export default async function vibeSessionsRoutes( fastify: FastifyInstance, - options: { coordinator: VibeSessionCoordinator; identityResolver: VibeIdentityResolver }, + options: { coordinator: VibeSessionCoordinator; identityResolver?: VibeIdentityResolver }, ) { const { coordinator, identityResolver } = options; - const requireUser = (request: FastifyRequest, reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }): string | null => { - const userId = identityResolver(request); - if (userId && validUuid(userId)) return userId; - reply.code(401).send({ error: 'A trusted Vibe identity is required' }); - return null; + const userFor = (request: FastifyRequest, reply: Reply) => { + const userId = requestUser(request, identityResolver); + return userId ? userId : (validationError(reply, 'x-user-id must be a UUID'), null); }; fastify.post('/v2/vibe/sessions', async (request, reply) => { - const userId = requireUser(request, reply); - if (!userId) return; - const body = isObject(request.body) ? request.body : {}; - if (body.resumeSessionId !== undefined && !validUuid(body.resumeSessionId)) { - return reply.code(400).send({ error: 'resumeSessionId must be a UUID' }); - } - if (body.resumeSessionId !== undefined && body.seedTrackId !== undefined) { - return reply.code(400).send({ error: 'resumeSessionId cannot be combined with seedTrackId' }); - } - if (body.seedTrackId !== undefined && !validUuid(body.seedTrackId)) { - return reply.code(400).send({ error: 'seedTrackId must be a UUID' }); - } - if (body.context !== undefined && !isObject(body.context)) { - return reply.code(400).send({ error: 'context must be an object' }); - } - if (isObject(body.context) && !isValidVibeContext(body.context)) { - return reply.code(400).send({ error: 'context contains an invalid structured Vibe value' }); - } - if (body.intent !== undefined && typeof body.intent !== 'string') { - return reply.code(400).send({ error: 'intent must be a string' }); - } - if (body.policyVersion !== undefined && (typeof body.policyVersion !== 'string' || !body.policyVersion.trim())) { - return reply.code(400).send({ error: 'policyVersion must be a non-empty string' }); - } + const userId = userFor(request, reply); + const input = parseStart(request.body); + if (!userId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined; try { - return reply.code(201).send(await coordinator.start(userId, { - seedTrackId: body.seedTrackId as string | undefined, - context: body.context as Record | undefined, - intent: body.intent as string | undefined, - policyVersion: body.policyVersion as string | undefined, - resumeSessionId: body.resumeSessionId as string | undefined, - })); + return reply.code(201).send(await coordinator.start(userId, input)); } catch (error) { return sendCoordinatorError(reply, error); } }); fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => { - const userId = requireUser(request, reply); - if (!userId) return; - const { sessionId } = request.params as { sessionId: string }; + const userId = userFor(request, reply); + const sessionId = sessionIdFrom(request, reply); const { version } = request.query as { version?: string }; - if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); const parsedVersion = version === undefined ? undefined : Number(version); - if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) { - return reply.code(400).send({ error: 'version must be a positive integer' }); - } + if (!userId || !sessionId) return; + if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) return validationError(reply, 'version must be a positive integer'); try { return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion)); } catch (error) { @@ -97,63 +133,21 @@ export default async function vibeSessionsRoutes( }); fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => { - const userId = requireUser(request, reply); - if (!userId) return; - const { sessionId } = request.params as { sessionId: string }; - const body = isObject(request.body) ? request.body : null; - if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); - if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) { - return reply.code(400).send({ error: 'type must be a supported Vibe event type' }); - } - // Delivery is an authoritative state transition performed only by /next. - // Accepting this event from the public ledger endpoint would let a client - // fabricate exposure rows and consume the server-side surprise budget. - if (body.type === 'track_served') { - return reply.code(400).send({ error: 'track_served is reserved for the server /next operation' }); - } - if (body.eventId !== undefined && !validUuid(body.eventId)) { - return reply.code(400).send({ error: 'eventId must be a UUID' }); - } - if (body.trackId !== undefined && !validUuid(body.trackId)) { - return reply.code(400).send({ error: 'trackId must be a UUID' }); - } - if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) { - return reply.code(400).send({ error: 'occurredAt must be an ISO-8601 timestamp' }); - } - if (body.positionMs !== undefined && (!Number.isInteger(body.positionMs) || (body.positionMs as number) < 0)) { - return reply.code(400).send({ error: 'positionMs must be a non-negative integer' }); - } - if (body.durationMs !== undefined && (!Number.isInteger(body.durationMs) || (body.durationMs as number) < 0)) { - return reply.code(400).send({ error: 'durationMs must be a non-negative integer' }); - } - if (body.payload !== undefined && !isObject(body.payload)) { - return reply.code(400).send({ error: 'payload must be an object' }); - } - if (body.type === 'context_changed' && isObject(body.payload) - && body.payload.context !== undefined - && (!isObject(body.payload.context) || !isValidVibeContext(body.payload.context))) { - return reply.code(400).send({ error: 'context_changed payload.context must be structured Vibe context' }); - } + const userId = userFor(request, reply); + const sessionId = sessionIdFrom(request, reply); + const input = parseEvent(request.body); + if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined; try { - return reply.send(await coordinator.appendEvent(userId, sessionId, { - eventId: body.eventId as string | undefined, - type: body.type as typeof VIBE_EVENT_TYPES[number], - trackId: body.trackId as string | undefined, - occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string), - positionMs: body.positionMs as number | undefined, - durationMs: body.durationMs as number | undefined, - payload: body.payload as Record | undefined, - })); + return reply.send(await coordinator.appendEvent(userId, sessionId, input)); } catch (error) { return sendCoordinatorError(reply, error); } }); fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => { - const userId = requireUser(request, reply); - if (!userId) return; - const { sessionId } = request.params as { sessionId: string }; - if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); + const userId = userFor(request, reply); + const sessionId = sessionIdFrom(request, reply); + if (!userId || !sessionId) return; try { return reply.send(await coordinator.end(userId, sessionId)); } catch (error) { @@ -161,55 +155,25 @@ export default async function vibeSessionsRoutes( } }); - fastify.post('/v2/vibe/sessions/:sessionId/next', async (request, reply) => { - const userId = requireUser(request, reply); - if (!userId) return; - const { sessionId } = request.params as { sessionId: string }; - const body = isObject(request.body) ? request.body : {}; - if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' }); - if (body.expectedPlanVersion !== undefined - && (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) { - return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' }); - } - const unplayable = body.unplayable; - if (unplayable !== undefined && !isObject(unplayable)) { - return reply.code(400).send({ error: 'unplayable must be an object' }); - } - if (isObject(unplayable)) { - if (body.expectedPlanVersion === undefined) { - return reply.code(400).send({ error: 'expectedPlanVersion is required when advancing an unplayable item' }); - } - if (!validUuid(unplayable.eventId) - || !validUuid(unplayable.planVersionId) - || !validUuid(unplayable.trackId) - || !Number.isInteger(unplayable.ordinal) - || (unplayable.ordinal as number) < 0) { - return reply.code(400).send({ error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' }); - } - } + fastify.post('/v2/vibe/sessions/:sessionId/advance', async (request, reply) => { + const userId = userFor(request, reply); + const sessionId = sessionIdFrom(request, reply); + const input = parseAdvance(request.body); + if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined; try { - const expectedPlanVersion = body.expectedPlanVersion as number | undefined; - if (isObject(unplayable)) { - return reply.send(await coordinator.advancePastUnplayable(userId, sessionId, { - expectedPlanVersion: expectedPlanVersion as number, - eventId: unplayable.eventId as string, - planVersionId: unplayable.planVersionId as string, - ordinal: unplayable.ordinal as number, - trackId: unplayable.trackId as string, - })); - } - return reply.send(expectedPlanVersion === undefined - ? await coordinator.serveNext(userId, sessionId) - : await coordinator.serveNext(userId, sessionId, expectedPlanVersion)); + return reply.send(input.unplayable + ? await coordinator.advancePastUnplayable(userId, sessionId, { expectedPlanVersion: input.expectedPlanVersion!, ...input.unplayable }) + : input.expectedPlanVersion === undefined + ? await coordinator.serveNext(userId, sessionId) + : await coordinator.serveNext(userId, sessionId, input.expectedPlanVersion)); } catch (error) { return sendCoordinatorError(reply, error); } }); } -function sendCoordinatorError(reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }, error: unknown) { - if (error instanceof VibeSessionNotFoundError) return reply.code(404).send({ error: error.message }); - if (error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message }); +function sendCoordinatorError(reply: Reply, error: unknown) { + if (error instanceof VibeSessionNotFoundError || error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message }); if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' }); throw error; } diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index fecad8e..fcd4dc6 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -75,9 +75,7 @@ describe('DbService v2 methods', () => { .mockResolvedValueOnce({ rows: [session] }) .mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] }); - await expect(service.createVibeSession({ - userId: 'user-1', policyVersion: 'v2.1', context: { activity: 'focus' }, - })).resolves.toEqual(session); + await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session); await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session); await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' }); @@ -88,17 +86,13 @@ describe('DbService v2 methods', () => { 'user-1', null, expect.any(String), 'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, ])); - expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({ - activity: 'focus', - })); + expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({}); expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2'); expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())'); expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); }); - it('normalizes context at the persistence boundary for direct callers', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z')); + it('starts sessions without inventing client context', async () => { const { service, clientQuery } = makeTransactionalService(); const session = { id: 'session-1', user_id: 'user-1', status: 'active' }; clientQuery @@ -108,33 +102,13 @@ describe('DbService v2 methods', () => { .mockResolvedValueOnce({ rows: [session] }) // insert .mockResolvedValueOnce({ rows: [] }); // COMMIT - try { - await service.createVibeSession({ - userId: 'user-1', - policyVersion: 'v2.1', - context: { - timeZone: 'UTC', - activity: 'walking', - device: 'phone', - exactCoordinates: '53.1959,50.1002', - browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' }, - localHour: 3, - weekday: 1, - }, - }); - - const insertParameters = clientQuery.mock.calls[3][1]; - expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]); - expect(JSON.parse(insertParameters[2])).toEqual({ - timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend', - activity: 'walking', device: 'phone', - }); - expect(insertParameters.slice(3)).toEqual([ - 'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, - ]); - } finally { - vi.useRealTimers(); - } + await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }); + const insertParameters = clientQuery.mock.calls[3][1]; + expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]); + expect(JSON.parse(insertParameters[2])).toEqual({}); + expect(insertParameters.slice(3)).toEqual([ + 'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, + ]); }); it('replaces an owned active session and writes its terminal event before starting another', async () => { @@ -189,45 +163,38 @@ describe('DbService v2 methods', () => { ); }); - it('sanitizes and projects an inserted context change atomically with its ledger event', async () => { + it('records a non-material event without mutating session context', async () => { const { service, clientQuery } = makeTransactionalService(); const event = { id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null, - type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, - payload: { context: { activity: 'walking', localHour: 12 } }, + type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { source: 'player' }, }; clientQuery .mockResolvedValueOnce({ rows: [] }) // BEGIN .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock .mockResolvedValueOnce({ rows: [event] }) // insert - .mockResolvedValueOnce({ rows: [] }) // session context projection - .mockResolvedValueOnce({ rows: [] }) // legacy session-state projection .mockResolvedValueOnce({ rows: [] }) // last event timestamp .mockResolvedValueOnce({ rows: [] }); // COMMIT await service.recordVibeEvent({ - sessionId: 'session-1', userId: 'user-1', type: 'context_changed', - payload: { - context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, - rawBrowserTelemetry: { battery: 4 }, - }, + sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' }, }); const values = clientQuery.mock.calls[2][1]; const storedPayload = JSON.parse(values[8]); - expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) }); - expect(storedPayload.context).not.toHaveProperty('exactCoordinates'); - expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry'); - expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb'); - expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'"); + expect(storedPayload).toEqual({ source: 'player' }); + expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain( + expect.stringContaining('SET context = $3::jsonb'), + ); }); - it('does not apply a retry body to an existing context-change event', async () => { + it('does not apply a retry body to an existing event', async () => { const { service, clientQuery } = makeTransactionalService(); const canonicalEvent = { id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null, - type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, - payload: { context: { activity: 'focus', localHour: 12 } }, + type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { source: 'player' }, }; clientQuery .mockResolvedValueOnce({ rows: [] }) // BEGIN @@ -236,8 +203,8 @@ describe('DbService v2 methods', () => { .mockResolvedValueOnce({ rows: [] }); // COMMIT const result = await service.recordVibeEvent({ - sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed', - payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } }, + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress', + payload: { source: 'retry' }, }); expect(result).toEqual({ event: canonicalEvent, inserted: false }); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 6c0a890..d0014de 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { Pool, PoolClient } from 'pg'; import { SearchService } from './search.service.js'; -import { normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; @@ -1515,18 +1514,12 @@ export class DbService { userId: string; policyVersion: string; seedTrackId?: string | null; - context?: Record; profile?: { goals: Record; explorationCoefficient: number; discoveryRadius: number; }; }): Promise { - // DbService is also used directly by workers and migrations. Keep the - // durable storage boundary canonical even when callers bypass the HTTP - // coordinator, so opaque or precise client telemetry can never become - // session context. - const canonicalContext = normalizeVibeContext(params.context ?? {}); return this.withTransaction(async (client) => { // Serialize starts for one listener even when there is no active row to // lock yet. The row lock below then safely replaces any prior session. @@ -1568,7 +1561,7 @@ export class DbService { [ params.userId, params.seedTrackId ?? null, - JSON.stringify(canonicalContext), + '{}', params.policyVersion, JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }), params.profile?.explorationCoefficient ?? 0.3, @@ -1615,29 +1608,6 @@ export class DbService { return (res.rows[0] as VibeSessionProfile | undefined) ?? null; } - /** Replace only the coarse, sanitised context attached to an active session. - * The immutable context_changed event remains the audit trail. */ - async updateVibeSessionContext(sessionId: string, userId: string, context: Record): Promise { - const canonicalContext = normalizeVibeContext(context); - await this.withTransaction(async client => { - const updated = await client.query( - `UPDATE vibe_sessions SET context = $3::jsonb, last_event_at = NOW() - WHERE id = $1 AND user_id = $2 AND status = 'active' - RETURNING id`, - [sessionId, userId, JSON.stringify(canonicalContext)], - ); - if (!updated.rows[0]) throw new Error('Vibe session was not found or is not owned by this user'); - await client.query( - `UPDATE session_state - SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), - state_vector = state_vector || jsonb_build_object('context', $3::jsonb), - last_interaction = NOW() - WHERE session_id = $1 AND user_id = $2`, - [sessionId, userId, JSON.stringify(canonicalContext)], - ); - }); - } - /** * Project unknown-track feedback into the session exploration controls. * This deliberately runs behind its own projection marker: the immutable @@ -1825,10 +1795,6 @@ export class DbService { durationMs?: number | null; payload?: Record; }): Promise { - // This service is also called by jobs and tests which bypass the HTTP - // route. Preserve the context privacy boundary at the final point before - // an immutable ledger write. - const payload = normalizeVibeEventPayload(params.type, params.payload); return this.withTransaction(async (client) => { // A session-row lock serializes both event writes and terminal state // transitions. In particular, it avoids the READ COMMITTED CTE snapshot @@ -1880,7 +1846,7 @@ export class DbService { occurredAt, params.positionMs ?? null, params.durationMs ?? null, - JSON.stringify(payload ?? {}), + JSON.stringify(params.payload ?? {}), ] ); const event = insertRes.rows[0] as VibeEvent | undefined; @@ -1889,8 +1855,6 @@ export class DbService { } await this.projectVibeFeedback(event, client); - await this.projectVibeContextChanged(event, client); - await client.query( `UPDATE vibe_sessions SET last_event_at = GREATEST(last_event_at, $2::timestamptz) @@ -1901,30 +1865,6 @@ export class DbService { }); } - /** Apply the context projection in the same transaction as its *inserted* - * ledger event. A client-event retry returns before this method, so its body - * can never overwrite session state with a different context. */ - private async projectVibeContextChanged(event: VibeEvent, client: PoolClient): Promise { - if (event.type !== 'context_changed') return; - const context = event.payload?.context; - if (!context || typeof context !== 'object' || Array.isArray(context)) return; - const canonicalContext = context as Record; - await client.query( - `UPDATE vibe_sessions - SET context = $3::jsonb - WHERE id = $1 AND user_id = $2 AND status = 'active'`, - [event.session_id, event.user_id, JSON.stringify(canonicalContext)], - ); - await client.query( - `UPDATE session_state - SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), - state_vector = state_vector || jsonb_build_object('context', $3::jsonb), - last_interaction = NOW() - WHERE session_id = $1 AND user_id = $2`, - [event.session_id, event.user_id, JSON.stringify(canonicalContext)], - ); - } - /** * Materialize Vibe feedback into the listener inputs used by the incumbent * director. The projection marker and every write share the event's diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 24f107d..5900190 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -1636,7 +1636,7 @@ export class SessionDirector { return scored.map(s => s.candidate); } - // session_state is otherwise only written once at /v2/vibe/start — persist the + // session_state is otherwise only written once when a Vibe session starts — persist the // freshly-computed state vector here so it evolves across the session instead of // buildState always reading back the boot defaults. async persistState(sessionId: string, userId: string, state: GeneratorContext['state']): Promise { diff --git a/backend/src/services/vibe-context.service.test.ts b/backend/src/services/vibe-context.service.test.ts deleted file mode 100644 index 9c1bc5d..0000000 --- a/backend/src/services/vibe-context.service.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { initialVibeState, normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; - -describe('Vibe context', () => { - it('keeps only coarse structured values and derives time on the server', () => { - const context = normalizeVibeContext({ - timeZone: 'UTC', activity: 'workout', device: 'headphones', - exactCoordinates: '53.2,50.1', localHour: 3, - }, new Date('2026-08-02T19:00:00.000Z')); - - expect(context).toMatchObject({ localHour: 19, weekday: 0, dayKind: 'weekend', activity: 'workout' }); - expect(context).not.toHaveProperty('exactCoordinates'); - expect(context).not.toHaveProperty('localHour', 3); - }); - - it('uses context only as a bounded initial prior and gives focus a comfort goal', () => { - const state = initialVibeState(normalizeVibeContext({ activity: 'focus' }, new Date('2026-08-03T12:00:00.000Z'))); - expect(state.energy).toBeGreaterThan(0); - expect(state.energy).toBeLessThan(1); - expect(state.sessionGoal).toEqual({ type: 'familiar', target: 1, progress: 0 }); - }); - - it('persists only canonical context for context-change events', () => { - const payload = normalizeVibeEventPayload('context_changed', { - context: { activity: 'walking', exactCoordinates: '53.2,50.1', adId: 'do-not-store' }, - rawBrowserTelemetry: { battery: 4 }, - }); - - expect(payload).toEqual({ - context: expect.objectContaining({ activity: 'walking' }), - }); - expect(payload).not.toHaveProperty('rawBrowserTelemetry'); - expect((payload?.context as Record)).not.toHaveProperty('exactCoordinates'); - expect((payload?.context as Record)).not.toHaveProperty('adId'); - }); -}); diff --git a/backend/src/services/vibe-context.service.ts b/backend/src/services/vibe-context.service.ts deleted file mode 100644 index 4c3b497..0000000 --- a/backend/src/services/vibe-context.service.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Coarse, opt-in context accepted by the durable Vibe API. It deliberately - * has no precise location, identifiers, or browser telemetry: clients can - * supply a hint, but the server owns the time fields and can ignore all of it. - */ -export const VIBE_CONTEXT_VALUES = { - device: ['desktop', 'phone', 'speaker', 'car', 'headphones'] as const, - activity: ['focus', 'relax', 'walking', 'workout', 'social', 'unknown'] as const, - locationCategory: ['home', 'work', 'gym', 'travel', 'unknown'] as const, - weather: ['clear', 'rain', 'snow', 'hot', 'cold', 'unknown'] as const, - source: ['current_track', 'artist', 'genre', 'surprise', 'resume'] as const, -}; - -export interface VibeContext { - timeZone?: string; - localHour?: number; - weekday?: number; - dayKind?: 'weekday' | 'weekend' | 'holiday'; - device?: (typeof VIBE_CONTEXT_VALUES.device)[number]; - activity?: (typeof VIBE_CONTEXT_VALUES.activity)[number]; - locationCategory?: (typeof VIBE_CONTEXT_VALUES.locationCategory)[number]; - weather?: (typeof VIBE_CONTEXT_VALUES.weather)[number]; - source?: (typeof VIBE_CONTEXT_VALUES.source)[number]; -} - -export interface InitialVibeState { - contextLabel: string | undefined; - energy: number; - noveltyHunger: number; - explorationCoefficient: number; - discoveryRadius: number; - sessionGoal: { type: 'surprise' | 'familiar' | 'discovery' | 'artist_introduction'; target: number; progress: number }; -} - -const hasValue = (values: T, value: unknown): value is T[number] => - typeof value === 'string' && (values as readonly string[]).includes(value); - -function serverTime(timeZone?: string, now = new Date()): Pick { - // Intl rejects bad IANA names. Falling back to the server clock is safe and - // still makes time a weak prior rather than client-controlled fact. - let zone: string | undefined; - try { - if (timeZone) new Intl.DateTimeFormat('en-US', { timeZone }).format(now); - zone = timeZone; - } catch { /* server-local fallback */ } - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: zone, hour: 'numeric', weekday: 'short', hourCycle: 'h23', - }).formatToParts(now); - const hour = Number(parts.find(part => part.type === 'hour')?.value ?? now.getHours()); - const weekdayName = parts.find(part => part.type === 'weekday')?.value; - const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName ?? ''); - return { - ...(zone ? { timeZone: zone } : {}), - localHour: Number.isInteger(hour) ? hour : now.getHours(), - weekday: weekday >= 0 ? weekday : now.getDay(), - dayKind: ([0, 6].includes(weekday >= 0 ? weekday : now.getDay()) ? 'weekend' : 'weekday'), - }; -} - -/** Remove unknown fields and derive time server-side. This preserves old - * clients that send an empty object while preventing opaque context blobs from - * becoming a permanent behavioural profile. */ -export function normalizeVibeContext(input: Record = {}, now = new Date()): VibeContext { - const time = serverTime(typeof input.timeZone === 'string' ? input.timeZone : undefined, now); - return { - ...time, - ...(hasValue(VIBE_CONTEXT_VALUES.device, input.device) ? { device: input.device } : {}), - ...(hasValue(VIBE_CONTEXT_VALUES.activity, input.activity) ? { activity: input.activity } : {}), - ...(hasValue(VIBE_CONTEXT_VALUES.locationCategory, input.locationCategory) ? { locationCategory: input.locationCategory } : {}), - ...(hasValue(VIBE_CONTEXT_VALUES.weather, input.weather) ? { weather: input.weather } : {}), - ...(hasValue(VIBE_CONTEXT_VALUES.source, input.source) ? { source: input.source } : {}), - }; -} - -/** - * Context changes are the only event payload with a structured, durable - * context body. Keep their ledger representation intentionally tiny: callers - * cannot smuggle precise location or arbitrary browser telemetry into an - * immutable event by adding sibling fields or unknown context keys. - */ -export function normalizeVibeEventPayload( - type: string, - payload?: Record, -): Record | undefined { - if (type !== 'context_changed') return payload; - const context = payload?.context; - if (!context || typeof context !== 'object' || Array.isArray(context)) return {}; - return { context: normalizeVibeContext(context as Record) }; -} - -/** Context is intentionally a gentle prior. It can nudge the initial arc but - * never overrides observed playback behaviour. */ -export function initialVibeState(context: VibeContext): InitialVibeState { - const activityEnergy: Record = { - focus: 0.42, relax: 0.34, walking: 0.58, workout: 0.72, social: 0.62, unknown: 0.5, - }; - const hour = context.localHour ?? 12; - const hourEnergy = hour < 6 ? 0.32 : hour < 10 ? 0.46 : hour >= 22 ? 0.38 : 0.52; - const activity = context.activity ?? 'unknown'; - const energy = Math.max(0, Math.min(1, activityEnergy[activity] * 0.7 + hourEnergy * 0.3)); - const goal = activity === 'focus' || activity === 'relax' - ? 'familiar' - : activity === 'workout' || activity === 'walking' ? 'surprise' : 'discovery'; - return { - contextLabel: context.activity ?? context.device, - energy, - noveltyHunger: 0.3, - explorationCoefficient: 0.3, - discoveryRadius: 0.38, - sessionGoal: { type: goal, target: 1, progress: 0 }, - }; -} - -export function isValidVibeContext(input: Record): boolean { - const scalar = (key: keyof typeof VIBE_CONTEXT_VALUES) => input[key] === undefined - || hasValue(VIBE_CONTEXT_VALUES[key], input[key]); - return (input.timeZone === undefined || typeof input.timeZone === 'string') - && scalar('device') && scalar('activity') && scalar('locationCategory') - && scalar('weather') && scalar('source'); -} diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index 3c771d8..070677a 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -14,7 +14,7 @@ const EVENT_ID = '33333333-3333-4333-8333-333333333333'; function session(status: 'active' | 'ended' = 'active') { return { id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null, - context: { activity: 'focus' }, policy_version: DEFAULT_VIBE_POLICY_VERSION, + context: {}, policy_version: DEFAULT_VIBE_POLICY_VERSION, started_at: new Date('2026-01-01T00:00:00.000Z'), last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null, } as any; @@ -63,15 +63,13 @@ describe('VibeSessionCoordinator', () => { it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => { const { db, director, coordinator } = setup(); - const response = await coordinator.start('user-1', { - context: { activity: 'focus' }, intent: 'deep-work', - }); + const response = await coordinator.start('user-1', {}); expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } }); expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION, })); - expect(db.createSessionState).toHaveBeenCalledWith('user-1', 'focus', expect.any(Object), SESSION_ID); + expect(db.createSessionState).toHaveBeenCalledWith('user-1', undefined, expect.any(Object), SESSION_ID); expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined); expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION_ID, version: 1, reason: 'session_started', @@ -154,38 +152,14 @@ describe('VibeSessionCoordinator', () => { expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); }); - it('normalizes context before the immutable event write', async () => { - const { db, coordinator } = setup(); - (db.recordVibeEvent as any).mockResolvedValueOnce({ - event: { id: 'event-1', type: 'context_changed', payload: {} }, inserted: true, - }); - - await coordinator.appendEvent('user-1', SESSION_ID, { - type: 'context_changed', - payload: { - context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, - rawBrowserTelemetry: { battery: 4 }, - }, - }); - - expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ - payload: { - context: expect.objectContaining({ activity: 'walking' }), - }, - })); - const payload = (db.recordVibeEvent as any).mock.calls[0][0].payload; - expect(payload).not.toHaveProperty('rawBrowserTelemetry'); - expect(payload.context).not.toHaveProperty('exactCoordinates'); - }); - - it('creates the durable profile from the initial context goal', async () => { + it('creates a neutral durable profile until listening behaviour provides evidence', async () => { const { db, coordinator } = setup(); - await coordinator.start('user-1', { context: { activity: 'focus' } }); + await coordinator.start('user-1', {}); expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ profile: expect.objectContaining({ - goals: { type: 'familiar', target: 1, progress: 0 }, + goals: { type: 'discovery', target: 1, progress: 0 }, explorationCoefficient: 0.3, discoveryRadius: 0.38, }), diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index ad0e0db..72b1ff5 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -1,12 +1,6 @@ import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; import { Candidate } from './generators.service.js'; -import { - initialVibeState, - normalizeVibeContext, - normalizeVibeEventPayload, - VibeContext, -} from './vibe-context.service.js'; /** * This is deliberately a narrow bridge between the durable Vibe ledger and @@ -16,7 +10,7 @@ import { export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial'; export const VIBE_EVENT_TYPES = [ - 'session_started', 'session_resumed', 'session_ended', 'context_changed', + 'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served', 'playback_started', 'progress', 'completed', 'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed', 'manual_search', 'album_opened', 'artist_opened', 'playlist_added', @@ -27,9 +21,6 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number]; export interface StartVibeSessionInput { seedTrackId?: string; - context?: VibeContext | Record; - intent?: string; - policyVersion?: string; resumeSessionId?: string; } @@ -79,14 +70,19 @@ export class VibeSessionCoordinator { async start(userId: string, input: StartVibeSessionInput): Promise { if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); - const context = normalizeVibeContext({ ...(input.context ?? {}) }); - const initialState = initialVibeState(context); - const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION; + // Vibe has no reliable device/activity/location signal. Start from neutral + // recommendation state and let actual listening behaviour shape the plan. + const initialState = { + energy: 0.5, + noveltyHunger: 0.3, + explorationCoefficient: 0.3, + discoveryRadius: 0.38, + sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 }, + }; const session = await this.db.createVibeSession({ userId, - policyVersion, + policyVersion: DEFAULT_VIBE_POLICY_VERSION, seedTrackId: input.seedTrackId ?? null, - context: { ...context }, profile: { goals: initialState.sessionGoal, explorationCoefficient: initialState.explorationCoefficient, @@ -99,14 +95,13 @@ export class VibeSessionCoordinator { // session while the durable tables remain the source of truth. await this.db.createSessionState( userId, - initialState.contextLabel ?? input.intent, + undefined, { energy: initialState.energy, noveltyHunger: initialState.noveltyHunger, explorationCoefficient: initialState.explorationCoefficient, discoveryRadius: initialState.discoveryRadius, sessionGoal: initialState.sessionGoal, - context, }, session.id, ); @@ -114,7 +109,7 @@ export class VibeSessionCoordinator { sessionId: session.id, userId, type: 'session_started', - payload: { policyVersion, context, intent: input.intent ?? null }, + payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION }, }); const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId); @@ -128,8 +123,7 @@ export class VibeSessionCoordinator { reason: 'session_started', stateSnapshot: state, objectiveSnapshot: { - policyVersion, - intent: input.intent ?? null, + policyVersion: DEFAULT_VIBE_POLICY_VERSION, horizonTracks: candidates.length, ...(candidates[0]?.plan?.objective ?? {}), }, @@ -203,7 +197,6 @@ export class VibeSessionCoordinator { // events are immutable. The DB repeats this boundary for non-HTTP // callers; keeping it here also makes coordinator callers see exactly // what will be persisted. - const payload = normalizeVibeEventPayload(input.type, input.payload); const result = await this.db.recordVibeEvent({ sessionId, userId, @@ -213,7 +206,7 @@ export class VibeSessionCoordinator { occurredAt: input.occurredAt, positionMs: input.positionMs, durationMs: input.durationMs, - payload, + payload: input.payload, }); // The ledger write is authoritative; this idempotent projection updates // exploration only after the exact event exists. Keep the compatibility diff --git a/docker-compose.yml b/docker-compose.yml index f9265a6..eb5ccf4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,10 +32,6 @@ services: TYPESENSE_API_KEY: ${TYPESENSE_API_KEY} MUZICK_API_KEY: ${MUZICK_API_KEY} MUZICK_ADMIN_KEY: ${MUZICK_ADMIN_KEY} - # Durable Vibe sessions are intentionally bound to this configured, - # server-trusted owner instead of accepting a client-supplied user id. - # Set it to the UUID of the local Muzick user in .env. - MUZICK_VIBE_USER_ID: ${MUZICK_VIBE_USER_ID} MUSIC_DIR: /music volumes: # READ-ONLY, deliberately. Nothing in the API request path may write to diff --git a/frontend/src/services/vibeService.test.ts b/frontend/src/services/vibeService.test.ts index 6542ac2..cd8bbe1 100644 --- a/frontend/src/services/vibeService.test.ts +++ b/frontend/src/services/vibeService.test.ts @@ -11,7 +11,7 @@ describe('durable vibe service', () => { await vibeService.next('session', 3); - expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/next', { expectedPlanVersion: 3 }); + expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3 }); }); it('uses an explicit idempotency key to advance a served but unplayable item', async () => { @@ -21,7 +21,7 @@ describe('durable vibe service', () => { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track', }); - expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/next', { + expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3, unplayable: { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track' }, }); diff --git a/frontend/src/services/vibeService.ts b/frontend/src/services/vibeService.ts index 7b22343..3cdf1cb 100644 --- a/frontend/src/services/vibeService.ts +++ b/frontend/src/services/vibeService.ts @@ -71,7 +71,7 @@ export const vibeService = { }, async next(sessionId: string, expectedPlanVersion: number): Promise { - const res = await api.post(`/v2/vibe/sessions/${sessionId}/next`, { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/advance`, { expectedPlanVersion, }); return res.data; @@ -82,7 +82,7 @@ export const vibeService = { expectedPlanVersion: number, unplayable: VibeUnplayableItemInput, ): Promise { - const res = await api.post(`/v2/vibe/sessions/${sessionId}/next`, { + const res = await api.post(`/v2/vibe/sessions/${sessionId}/advance`, { expectedPlanVersion, unplayable, }); diff --git a/frontend/src/services/vibeSession.ts b/frontend/src/services/vibeSession.ts index b6332da..80d644a 100644 --- a/frontend/src/services/vibeSession.ts +++ b/frontend/src/services/vibeSession.ts @@ -202,7 +202,7 @@ function isSessionTerminalError(error: unknown): boolean { export function vibeErrorMessage(error: unknown): string { if (!axios.isAxiosError(error)) return 'Could not refresh this Vibe. Please try again.'; switch (error.response?.status) { - case 401: return 'Vibe needs a trusted local user identity. Set MUZICK_VIBE_USER_ID and try again.'; + case 400: return 'Vibe needs a valid user identity.'; case 404: return 'This Vibe session is no longer available.'; case 409: return 'This Vibe session has already ended or was replaced.'; default: return 'Could not refresh this Vibe. Please try again.'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d8c7ba9..e4d8c9f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -75,8 +75,8 @@ export interface HealthResponse { redis: 'ok' | 'error' | 'unknown'; } -// Active v2 recommendation session. The sessionId comes from -// POST /api/v2/vibe/start and identifies the Redis-stored plan. +// Active durable recommendation session. The sessionId comes from +// POST /api/v2/vibe/sessions and identifies its persisted plan. export interface VibeSession { sessionId: string; seedTrackId: string | null;