//! Shared request/response DTOs and result-mapping for the data routes. //! //! The standalone router ([`crate::router`]) and the cluster router //! ([`crate::cluster`]) expose the SAME data surface (`/items`, //! `/embeddings`, `/signals`, `/feed`, `/search`). Their request/response //! shapes and the engine-result → JSON mapping were previously duplicated //! verbatim across both modules; any drift between the two (e.g. a new field //! added on one side only) would silently change one mode's API. This module //! is the single source of truth so the two routers cannot diverge. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use tidaldb::{ query::{retrieve::RetrieveResult, search::SearchResultItem}, storage::vector::VectorSearchResult, }; use utoipa::ToSchema; // ── Request DTOs ───────────────────────────────────────────────────────────── /// `POST /items` body. /// /// `Serialize` so the multi-process cluster can forward / broadcast the verbatim /// body to the leader or peers (m8p10 task 03). #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ItemRequest { /// Entity ID of the item to create. #[schema(example = 1)] pub entity_id: u64, /// Arbitrary string→string metadata (e.g. `title`, `category`, `created_at`). pub metadata: HashMap, } /// `POST /embeddings` body. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct EmbeddingRequest { /// Entity ID the embedding belongs to. #[schema(example = 1)] pub entity_id: u64, /// Dense embedding vector. The database retrieves and ranks over these /// vectors; it does not generate them. pub values: Vec, } /// `POST /signals` body. /// /// `user_id` / `creator_id` are optional signal context. They are read on the /// standalone path; the cluster path ignores them (its replication layer does /// not yet carry per-signal context), so defaulting them keeps both modes' /// wire contract identical. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SignalRequest { /// Entity the signal is recorded against. #[schema(example = 1)] pub entity_id: u64, /// Signal type name (e.g. `view`, `like`, `share`). #[schema(example = "view")] pub signal: String, /// Signal weight applied to the running decay score. #[schema(example = 1.0)] pub weight: f64, /// Optional originating user context (standalone path only). #[serde(default)] pub user_id: Option, /// Optional originating creator context (standalone path only). #[serde(default)] pub creator_id: Option, } /// `POST /vector_search` body — the m12p1 recall-measurement probe. /// /// A raw query vector goes in the body (1536 floats do not belong in a GET query /// string), and the engine returns the `k` items whose stored content embedding /// is nearest by the slot's distance metric — **no** profile scoring, fusion, or /// diversity. Comparing this against a brute-force cosine ground truth yields the /// ANN recall@k the harness reports. /// /// `Serialize` so the cluster region node can forward the verbatim body to a peer /// region on a `?region=` read, exactly like the other write/read DTOs. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct VectorSearchRequest { /// Dense query vector. Must match the item content slot's dimensionality. pub vector: Vec, /// Number of nearest neighbors to return; clamped to `MAX_LIMIT` (1000) at /// the trust boundary. Defaults to 10 (the recall@10 target's `k`). #[serde(default = "default_k")] #[schema(example = 10)] pub k: u32, /// Optional per-request HNSW beam width override (m12p3 makes this the /// recall/latency knob). Omitted = the slot's configured default. #[serde(default)] pub ef_search: Option, /// Internal cross-shard read selector (m12p4). When the cluster gateway fans /// a corpus-wide probe out to a node that hosts only some shard groups, it /// sets this on the per-group internal hop so the remote serves ONLY group /// `shard` and never re-fans-out. Absent on every external request — clients /// never set it; the gateway merges the per-group slices itself. #[serde(default)] pub shard: Option, } impl VectorSearchRequest { /// The requested `k` clamped to [`MAX_LIMIT`] — the single enforcement point /// for the network trust boundary, mirroring the `/feed` and `/search` /// `clamped_limit`. An oversized `k` can never size the engine's result /// buffer. #[must_use] pub const fn clamped_k(&self) -> usize { (if self.k > MAX_LIMIT { MAX_LIMIT } else { self.k }) as usize } /// The `ef_search` override as a `usize`, if any. #[must_use] pub fn ef_search(&self) -> Option { self.ef_search.map(|v| v as usize) } } /// Default `k` (nearest-neighbor count) when `k` is omitted: the recall@10 `k`. #[must_use] pub const fn default_k() -> u32 { 10 } /// Maximum page size a client may request via `?limit=`. /// /// `limit` arrives across the network trust boundary and feeds the engine's /// candidate cap directly. Left unbounded, a single `?limit=4000000000` request /// would drive the retrieval/scoring pipeline to size buffers for a four-billion /// row result, a trivial memory-amplification `DoS`. The page-ranking surfaces /// (`/feed`, `/search`) are interactive UIs — a few hundred ranked items is the /// realistic ceiling — so 1000 is a generous-but-finite cap. Bulk export uses a /// separate, much larger ceiling (`ExportRequest::MAX_EXPORT_LIMIT`); this is /// deliberately the smaller interactive-read limit. Callers clamp with /// [`FeedQuery::clamped_limit`] / [`SearchQueryParams::clamped_limit`] at the /// boundary rather than trusting the raw value. pub const MAX_LIMIT: u32 = 1000; /// `GET /feed` query parameters. #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] #[into_params(parameter_in = Query)] pub struct FeedQuery { /// Optional user to personalize ranking for. #[serde(default)] pub user_id: Option, /// Named ranking profile; defaults to `for_you`. #[serde(default = "default_profile")] #[param(example = "for_you")] pub profile: String, /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary. #[serde(default = "default_limit")] #[param(example = 20)] pub limit: u32, /// Target region (cluster mode only; rejected with 400 standalone). #[serde(default)] pub region: Option, /// Seed item for "more like this" (m12p2): with `profile=related`, the /// engine resolves this item's embedding and sources candidates by ANN /// nearest-neighbour over it. Ignored by profiles that do not use it. #[serde(default)] #[param(example = 42)] pub similar_to: Option, /// Internal cross-shard read selector (m12p4). The cluster gateway sets this /// on the per-group internal hop when it fans a corpus-wide `/feed` out to a /// node hosting a strict subset of shard groups, so the remote reads ONLY /// group `shard` and never re-fans-out. Absent on every external request — /// clients never set it; the gateway merges the per-group slices itself. #[serde(default)] #[param(example = 0)] pub shard: Option, } impl FeedQuery { /// The requested page size clamped to [`MAX_LIMIT`]. /// /// Always call this instead of reading `limit` directly: it is the single /// enforcement point for the network trust boundary, so an oversized client /// value can never reach the engine's candidate cap. #[must_use] pub const fn clamped_limit(&self) -> u32 { if self.limit > MAX_LIMIT { MAX_LIMIT } else { self.limit } } } /// `GET /search` query parameters. #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] #[into_params(parameter_in = Query)] pub struct SearchQueryParams { /// Free-text query string (BM25 + ANN hybrid). #[param(example = "jazz piano")] pub query: String, /// Optional user to personalize ranking for. #[serde(default)] pub user_id: Option, /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary. #[serde(default = "default_limit")] #[param(example = 20)] pub limit: u32, /// Target region (cluster mode only; rejected with 400 standalone). #[serde(default)] pub region: Option, /// Internal cross-shard read selector (m12p4). The cluster gateway sets this /// on the per-group internal hop when it fans a corpus-wide `/search` out to /// a node hosting a strict subset of shard groups, so the remote searches /// ONLY group `shard` and never re-fans-out. Absent on every external /// request — clients never set it; the gateway merges the per-group slices. #[serde(default)] #[param(example = 0)] pub shard: Option, } impl SearchQueryParams { /// The requested page size clamped to [`MAX_LIMIT`]. /// /// Always call this instead of reading `limit` directly: it is the single /// enforcement point for the network trust boundary, so an oversized client /// value can never reach the engine's candidate cap. #[must_use] pub const fn clamped_limit(&self) -> u32 { if self.limit > MAX_LIMIT { MAX_LIMIT } else { self.limit } } } /// Default ranking profile when `?profile=` is omitted. #[must_use] pub fn default_profile() -> String { "for_you".into() } /// Default page size when `?limit=` is omitted. #[must_use] pub const fn default_limit() -> u32 { 20 } // ── Response DTOs ──────────────────────────────────────────────────────────── /// `GET /feed` response body. #[derive(Debug, Serialize, ToSchema)] pub struct FeedResponse { /// Ranked items, best-first. pub items: Vec, /// Total candidates considered before ranking/diversity. pub total_candidates: usize, /// Region the feed was served from (cluster mode); `null` standalone. pub region: Option, /// Names of shard groups that could not be reached for this cross-shard read /// (m12p4). Present ONLY when the result is degraded — a complete read omits /// it. Its presence tells the client the page is PARTIAL (fewer items and a /// lower `total_candidates` than a complete read), so a degraded read is never /// silently indistinguishable from a complete one. #[serde(skip_serializing_if = "Option::is_none")] pub unavailable_shards: Option>, } /// One ranked item in a feed response. #[derive(Debug, Serialize, ToSchema)] pub struct FeedItem { /// Entity ID of the ranked item. pub entity_id: u64, /// Final ranking score. pub score: f64, /// 1-based rank within the page. pub rank: usize, /// Signal snapshot for explainability; omitted when empty. #[serde(skip_serializing_if = "Option::is_none")] pub signals: Option>, } /// A signal value snapshot attached to a ranked item, for explainability. #[derive(Debug, Serialize, ToSchema)] pub struct SignalValue { /// Signal type name. pub name: String, /// Decayed signal value at query time. pub value: f64, } /// `GET /search` response body. #[derive(Debug, Serialize, ToSchema)] pub struct SearchResponse { /// Ranked search results, best-first. pub items: Vec, /// Total candidates considered before ranking/diversity. pub total_candidates: usize, /// Region the search was served from (cluster mode); `null` standalone. pub region: Option, /// Shard groups unreachable for this cross-shard read (m12p4); present ONLY /// when the result is degraded (partial page), omitted on a complete read. /// See [`FeedResponse::unavailable_shards`]. #[serde(skip_serializing_if = "Option::is_none")] pub unavailable_shards: Option>, } /// One ranked item in a search response. #[derive(Debug, Serialize, ToSchema)] pub struct SearchItem { /// Entity ID of the ranked item. pub entity_id: u64, /// Final fused ranking score. pub score: f64, /// 1-based rank within the page. pub rank: usize, /// BM25 lexical component; omitted when the query had no text match path. #[serde(skip_serializing_if = "Option::is_none")] pub bm25_score: Option, /// Semantic (ANN) component; omitted when no vector match path ran. #[serde(skip_serializing_if = "Option::is_none")] pub semantic_score: Option, } /// `POST /vector_search` response body — the raw ANN result, closest-first. #[derive(Debug, Serialize, ToSchema)] pub struct VectorSearchResponse { /// Nearest items, ordered by ascending distance (closest first). pub items: Vec, /// Always `null`: the recall probe serves locally (standalone, or — in /// cluster mode — merged across the node's hosted shard groups), so there is /// no single serving region to report. pub region: Option, /// Shard groups unreachable for this cross-shard probe (m12p4); present ONLY /// when the result is degraded (partial nearest-set), omitted when complete. /// See [`FeedResponse::unavailable_shards`]. #[serde(skip_serializing_if = "Option::is_none")] pub unavailable_shards: Option>, } /// One nearest-neighbor match: the entity and its distance from the query. #[derive(Debug, Serialize, ToSchema)] pub struct VectorMatch { /// Entity ID of the matched item. pub entity_id: u64, /// L2-squared distance from the query vector (lower = more similar). For the /// L2-normalized vectors tidalDB stores, this lies in `[0.0, 4.0]` and is /// monotonic with cosine distance. pub distance: f32, } /// Map one engine [`VectorSearchResult`] into a wire [`VectorMatch`]. #[must_use] pub const fn vector_match(r: &VectorSearchResult) -> VectorMatch { VectorMatch { entity_id: r.id, distance: r.distance, } } /// Map a slice of engine [`VectorSearchResult`]s into wire [`VectorMatch`]es. #[must_use] pub fn vector_matches(items: &[VectorSearchResult]) -> Vec { items.iter().map(vector_match).collect() } // ── Engine-result → DTO mapping ────────────────────────────────────────────── /// Map one engine [`RetrieveResult`] into a wire [`FeedItem`]. /// /// An empty signal list serializes as absent (`None`) rather than `[]` so the /// response stays compact. #[must_use] pub fn feed_item(item: &RetrieveResult) -> FeedItem { let signals = if item.signals.is_empty() { None } else { Some( item.signals .iter() .map(|s| SignalValue { name: s.name.clone(), value: s.value, }) .collect(), ) }; FeedItem { entity_id: item.entity_id.as_u64(), score: item.score, rank: item.rank, signals, } } /// Map a slice of engine [`RetrieveResult`]s into wire [`FeedItem`]s. #[must_use] pub fn feed_items(items: &[RetrieveResult]) -> Vec { items.iter().map(feed_item).collect() } /// Map one engine [`SearchResultItem`] into a wire [`SearchItem`]. #[must_use] pub fn search_item(item: &SearchResultItem) -> SearchItem { SearchItem { entity_id: item.entity_id.as_u64(), score: item.score, rank: item.rank, bm25_score: item.bm25_score.map(f64::from), semantic_score: item.semantic_score.map(f64::from), } } /// Map a slice of engine [`SearchResultItem`]s into wire [`SearchItem`]s. #[must_use] pub fn search_items(items: &[SearchResultItem]) -> Vec { items.iter().map(search_item).collect() } #[cfg(test)] mod tests { use super::*; fn feed_query(limit: u32) -> FeedQuery { FeedQuery { user_id: None, profile: default_profile(), limit, region: None, similar_to: None, shard: None, } } fn search_query(limit: u32) -> SearchQueryParams { SearchQueryParams { query: "q".into(), user_id: None, limit, region: None, shard: None, } } #[test] fn feed_limit_clamped_at_boundary() { // An oversized client-supplied limit is capped, never passed through. assert_eq!(feed_query(u32::MAX).clamped_limit(), MAX_LIMIT); assert_eq!(feed_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT); // Values at or below the cap pass through unchanged. assert_eq!(feed_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT); assert_eq!(feed_query(default_limit()).clamped_limit(), default_limit()); assert_eq!(feed_query(0).clamped_limit(), 0); } #[test] fn search_limit_clamped_at_boundary() { assert_eq!(search_query(u32::MAX).clamped_limit(), MAX_LIMIT); assert_eq!(search_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT); assert_eq!(search_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT); assert_eq!( search_query(default_limit()).clamped_limit(), default_limit() ); } }