# Task 01: RETRIEVE AST + Parser ## Context **Milestone:** 2 -- Ranked Retrieval **Phase:** m2p5 -- Query Parser and RETRIEVE Executor **Depends On:** None (uses types from m2p2, m2p3, m2p4 but no m2p5 tasks) **Blocks:** Task 02 (RETRIEVE Executor Pipeline), Task 03 (M2 UAT Integration Test) **Complexity:** M ## Objective Deliver the typed AST for the RETRIEVE query operation and a Rust builder API for constructing queries ergonomically. For M2, there is no text grammar parser -- the "parser" is the `RetrieveBuilder` which validates and constructs a `Retrieve` struct. The text syntax parser (`RETRIEVE items USING PROFILE trending LIMIT 25`) is deferred to M5. This task also defines the response types (`Results`, `RetrieveResult`), the pagination cursor, the `Signal` write command struct (wired to the existing M1 signal write path), and the `QueryError` enum. These types are consumed by Task 02's executor and returned to the caller. The types defined here map directly to the spec's input/output types (Spec 08 Section 3) but are scoped to M2: no `for_user`, no `similar_to`, no `for_cohort`, no `window`, no `context`, no `for_session`. These fields exist on the struct as `Option` types for forward compatibility but are validated as unsupported in M2 if set. ## Requirements - `Retrieve` struct: the complete RETRIEVE query request - `RetrieveBuilder`: ergonomic builder pattern for constructing `Retrieve` queries - `ProfileRef`: profile name + optional version reference - `Cursor`: opaque offset-based pagination cursor with base64 encoding - `Results`: the query response (items, cursor, total_scored, constraints_satisfied) - `RetrieveResult`: one result item (entity_id, score, rank, signal_snapshot) - `Signal`: write command struct wired to `TidalDb::signal()` - `QueryError`: error enum for query validation and execution failures - Validation: limit range, profile reference format, filter compatibility - No `unsafe` code ## Technical Design ### Module Structure ``` tidal/src/ query/ mod.rs -- pub mod retrieve; re-exports retrieve.rs -- all types from this task ``` ### Public API ```rust // === query/retrieve.rs === use crate::schema::{EntityId, EntityKind, Timestamp}; use crate::ranking::diversity::DiversityConstraints; use crate::storage::indexes::filter::FilterExpr; /// Reference to a ranking profile by name, optionally pinned to a version. /// /// The executor resolves this against the `ProfileRegistry` at query time. /// If `version` is `None`, the latest version is used. #[derive(Debug, Clone)] pub struct ProfileRef { /// Profile name. Must match a registered profile in the registry. pub name: String, /// Optional version pin. `None` = latest version. pub version: Option, } impl ProfileRef { pub fn new(name: impl Into) -> Self { Self { name: name.into(), version: None, } } pub fn versioned(name: impl Into, version: u32) -> Self { Self { name: name.into(), version: Some(version), } } } /// A RETRIEVE query. Declarative: specifies what, not how. /// /// For M2, this struct is constructed via `RetrieveBuilder` (Rust API). /// A text syntax parser is deferred to M5. /// /// The profile determines the candidate generation strategy and scoring /// formula. The caller never specifies how candidates are found -- only /// which profile to use and which filters to apply. /// /// Spec reference: docs/specs/08-query-engine.md Section 3.1 #[derive(Debug, Clone)] pub struct Retrieve { /// Target entity type. For M2, only `EntityKind::Item` is supported. pub entity_kind: EntityKind, /// Named ranking profile. Determines candidate strategy and scoring. pub profile: ProfileRef, /// Metadata and signal filters. Combined as AND. /// Uses `FilterExpr` from m2p2 for composable filter evaluation. pub filters: Vec, /// Diversity constraints. Applied as a post-scoring pass. /// If `None`, no diversity enforcement is applied. pub diversity: Option, /// Maximum results to return. Default: 50. Range: [1, 500]. pub limit: usize, /// Explicit item exclusions. Removed from candidate set before scoring. pub exclude: Vec, /// Pagination cursor from a previous result set. /// If `None`, returns the first page. pub cursor: Option, // --- Fields present for forward compatibility (M3+), validated as unsupported in M2 --- /// User context for personalization. M3+. pub for_user: Option, /// Anchor item for related/similar queries. M3+. pub similar_to: Option, /// Surface context for the feedback loop. M3+. pub context: Option, } /// Builder for constructing `Retrieve` queries ergonomically. /// /// # Example /// /// ```ignore /// let query = Retrieve::builder() /// .entity(EntityKind::Item) /// .profile("trending") /// .filter(FilterExpr::eq("category", "jazz")) /// .diversity(DiversityConstraints::new().max_per_creator(2)) /// .limit(25) /// .build()?; /// ``` pub struct RetrieveBuilder { entity_kind: Option, profile: Option, filters: Vec, diversity: Option, limit: usize, exclude: Vec, cursor: Option, for_user: Option, similar_to: Option, context: Option, } impl RetrieveBuilder { pub fn new() -> Self { Self { entity_kind: None, profile: None, filters: Vec::new(), diversity: None, limit: 50, exclude: Vec::new(), cursor: None, for_user: None, similar_to: None, context: None, } } /// Set the target entity kind. pub fn entity(mut self, kind: EntityKind) -> Self { self.entity_kind = Some(kind); self } /// Set the ranking profile by name. pub fn profile(mut self, name: impl Into) -> Self { self.profile = Some(ProfileRef::new(name)); self } /// Set the ranking profile by name and version. pub fn profile_versioned(mut self, name: impl Into, version: u32) -> Self { self.profile = Some(ProfileRef::versioned(name, version)); self } /// Add a filter expression. Multiple filters are ANDed together. pub fn filter(mut self, expr: FilterExpr) -> Self { self.filters.push(expr); self } /// Set diversity constraints. pub fn diversity(mut self, constraints: DiversityConstraints) -> Self { self.diversity = Some(constraints); self } /// Set the maximum number of results. Range: [1, 500]. Default: 50. pub fn limit(mut self, limit: usize) -> Self { self.limit = limit; self } /// Add an entity ID to the exclusion list. pub fn exclude(mut self, id: EntityId) -> Self { self.exclude.push(id); self } /// Add multiple entity IDs to the exclusion list. pub fn exclude_ids(mut self, ids: impl IntoIterator) -> Self { self.exclude.extend(ids); self } /// Set the pagination cursor. pub fn cursor(mut self, cursor: Cursor) -> Self { self.cursor = Some(cursor); self } /// Validate and build the `Retrieve` query. /// /// Returns `QueryError::InvalidLimit` if limit is 0 or > 500. /// Returns `QueryError::ProfileNotFound` if no profile is set. /// Returns `QueryError::InvalidFilter` if `for_user` or `similar_to` are set (M2). pub fn build(self) -> Result { let entity_kind = self.entity_kind.unwrap_or(EntityKind::Item); let profile = self.profile.ok_or_else(|| { QueryError::ProfileNotFound("no profile specified".to_string()) })?; if self.limit == 0 || self.limit > 500 { return Err(QueryError::InvalidLimit { requested: self.limit, min: 1, max: 500, }); } // M2: reject unsupported features if self.for_user.is_some() { return Err(QueryError::InvalidFilter { field: "for_user".to_string(), reason: "FOR USER clause is not supported until M3".to_string(), }); } if self.similar_to.is_some() { return Err(QueryError::InvalidFilter { field: "similar_to".to_string(), reason: "SIMILAR TO clause is not supported until M3".to_string(), }); } Ok(Retrieve { entity_kind, profile, filters: self.filters, diversity: self.diversity, limit: self.limit, exclude: self.exclude, cursor: self.cursor, for_user: self.for_user, similar_to: self.similar_to, context: self.context, }) } } impl Default for RetrieveBuilder { fn default() -> Self { Self::new() } } impl Retrieve { /// Start building a RETRIEVE query. pub fn builder() -> RetrieveBuilder { RetrieveBuilder::new() } } /// The combined filter expression for the query. /// /// Multiple filters are ANDed together. This helper constructs the /// combined filter from the `Retrieve` query's filter list. impl Retrieve { /// Combine all filters into a single AND expression. /// Returns `None` if no filters are specified. pub fn combined_filter(&self) -> Option { match self.filters.len() { 0 => None, 1 => Some(self.filters[0].clone()), _ => Some(FilterExpr::And(self.filters.clone())), } } } // ============================================================ // Response Types // ============================================================ /// The complete response from a RETRIEVE query. /// /// Spec reference: docs/specs/08-query-engine.md Section 5.7 #[derive(Debug, Clone)] pub struct Results { /// The ranked result items for this page. pub items: Vec, /// Cursor for the next page. `None` if this is the last page. pub next_cursor: Option, /// How many candidates were scored by the profile executor. /// This is the count after filtering but before diversity and limit. pub total_scored: usize, /// Whether all diversity constraints were fully satisfied. /// `false` if constraints were relaxed (see `DiversityResult::violations`). pub constraints_satisfied: bool, /// Non-fatal warnings from query execution. /// /// Warnings are surfaced when the executor degrades gracefully: /// - Metadata enrichment fails for a candidate (the item is treated as a /// unique creator for diversity purposes; results are still returned) /// - A filter references a field with no index (predicate fallback used) /// /// An empty `warnings` vec means clean execution with no degradation. pub warnings: Vec, } impl Results { /// Number of items in this page. pub fn len(&self) -> usize { self.items.len() } /// Whether this page is empty. pub fn is_empty(&self) -> bool { self.items.is_empty() } } /// A single result item from a RETRIEVE query. /// /// Includes the entity ID, composite score, rank position, and a /// signal snapshot for debugging and transparency. /// /// Spec reference: docs/specs/08-query-engine.md Section 5, Stage 10 #[derive(Debug, Clone)] pub struct RetrieveResult { /// The entity ID of the result. pub entity_id: EntityId, /// The composite score from the ranking profile, normalized to [0.0, 1.0]. pub score: f64, /// The 1-based rank position in the result set. pub rank: usize, /// Key signal values used in scoring. For debugging and transparency. /// Contains (signal_name, value) pairs for signals referenced by the /// profile's scoring rules. Capped at 10 entries. pub signal_snapshot: Vec<(String, f64)>, } // ============================================================ // Pagination Cursor // ============================================================ /// Opaque pagination cursor for RETRIEVE queries. /// /// For M2, this is a simple offset-based cursor encoded as a base64 string. /// True keyset-based pagination (score + entity_id tiebreaker, Spec 08 /// Section 8.2) is deferred to M5. /// /// # Limitation: Not Stable Under Concurrent Writes /// /// Offset-based cursors are not stable when the underlying ranked list /// changes between page requests (e.g., due to concurrent signal writes). /// Items may appear on multiple pages or be skipped if the ranking shifts. /// This is documented and acceptable for M2; the spec says to prefer /// keyset cursors for production use. Do not use cursor-based pagination /// in write-heavy workloads until M5. /// /// The cursor is opaque to the caller -- they receive it as a string and /// pass it back on the next request. The internal representation is an /// implementation detail. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { /// The offset into the full result set for the next page. offset: usize, } impl Cursor { /// Create a cursor from an offset. pub(crate) fn from_offset(offset: usize) -> Self { Self { offset } } /// Get the offset this cursor represents. pub(crate) fn offset(&self) -> usize { self.offset } /// Encode the cursor as an opaque base64 string. pub fn encode(&self) -> String { use base64::Engine as _; let bytes = self.offset.to_le_bytes(); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } /// Decode a cursor from an opaque base64 string. pub fn decode(encoded: &str) -> Result { use base64::Engine as _; let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(encoded) .map_err(|e| QueryError::InvalidCursor(format!("invalid base64: {e}")))?; if bytes.len() != std::mem::size_of::() { return Err(QueryError::InvalidCursor(format!( "expected {} bytes, got {}", std::mem::size_of::(), bytes.len() ))); } let offset = usize::from_le_bytes( bytes .try_into() .map_err(|_| QueryError::InvalidCursor("byte conversion failed".to_string()))?, ); Ok(Self { offset }) } } // ============================================================ // Signal Write Command // ============================================================ /// A signal write command. /// /// For M2, this is a thin wrapper that routes to the existing /// `TidalDb::signal()` method from M1. The struct form enables /// future batching and the query language parser (M5) to produce /// signal writes from parsed text. #[derive(Debug, Clone)] pub struct Signal { /// The signal type name (e.g., "view", "like", "share"). pub signal_type: String, /// The target entity ID. pub entity_id: EntityId, /// The signal weight. Typically 1.0 for count-based signals. pub weight: f64, /// The timestamp of the event. pub timestamp: Timestamp, } impl Signal { /// Create a new signal write command. pub fn new( signal_type: impl Into, entity_id: EntityId, weight: f64, timestamp: Timestamp, ) -> Self { Self { signal_type: signal_type.into(), entity_id, weight, timestamp, } } } // ============================================================ // Query Error // ============================================================ /// Errors returned by the query engine. /// /// Spec reference: docs/specs/08-query-engine.md Section 3.4 #[derive(Debug, Clone)] pub enum QueryError { /// The named profile does not exist in the profile registry. ProfileNotFound(String), /// A filter references a field or uses a condition that is invalid. InvalidFilter { field: String, reason: String, }, /// The requested limit is out of the valid range [1, 500]. InvalidLimit { requested: usize, min: usize, max: usize, }, /// A required index (vector, bitmap, range) is not available. IndexNotAvailable(String), /// A storage engine error occurred during query execution. /// /// Preserves the original error type so callers can match on specific /// storage failure modes (e.g., `StorageError::Corruption`). Use /// `From` for automatic conversion. StorageError(crate::storage::StorageError), /// The pagination cursor is invalid or could not be decoded. InvalidCursor(String), /// The profile's candidate strategy is not supported in this milestone. /// M2 supports: Ann, Scan, SignalRanked. Others require M3+ infrastructure. UnsupportedStrategy(String), } impl std::fmt::Display for QueryError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { QueryError::ProfileNotFound(name) => { write!(f, "ranking profile '{name}' not found in registry") } QueryError::InvalidFilter { field, reason } => { write!(f, "invalid filter on field '{field}': {reason}") } QueryError::InvalidLimit { requested, min, max, } => { write!(f, "limit {requested} is out of range [{min}, {max}]") } QueryError::IndexNotAvailable(name) => { write!(f, "required index not available: {name}") } QueryError::StorageError(e) => { write!(f, "storage error during query: {e}") } QueryError::InvalidCursor(msg) => { write!(f, "invalid pagination cursor: {msg}") } QueryError::UnsupportedStrategy(msg) => { write!(f, "unsupported candidate strategy: {msg}") } } } } impl std::error::Error for QueryError {} impl From for QueryError { fn from(e: crate::storage::StorageError) -> Self { QueryError::StorageError(e) } } ``` ### Validation Logic ```rust impl Retrieve { /// Validate the query against a ProfileRegistry and Schema. /// /// Called by the executor before pipeline execution. Separated from /// `build()` because profile existence requires the registry, which /// the builder does not have access to. pub(crate) fn validate( &self, registry: &ProfileRegistry, ) -> Result<(), QueryError> { // 1. Profile existence let profile_name = &self.profile.name; let profile = match self.profile.version { Some(v) => registry.get_versioned(profile_name, v), None => registry.get(profile_name), }; if profile.is_none() { return Err(QueryError::ProfileNotFound(profile_name.clone())); } // 2. Limit range (already validated in builder, but defense in depth) if self.limit == 0 || self.limit > 500 { return Err(QueryError::InvalidLimit { requested: self.limit, min: 1, max: 500, }); } // 3. M2: unsupported features if self.for_user.is_some() { return Err(QueryError::InvalidFilter { field: "for_user".to_string(), reason: "FOR USER clause requires M3".to_string(), }); } if self.similar_to.is_some() { return Err(QueryError::InvalidFilter { field: "similar_to".to_string(), reason: "SIMILAR TO clause requires M3".to_string(), }); } // 4. Candidate strategy support check let resolved_profile = profile.unwrap(); match resolved_profile.candidate_strategy() { CandidateStrategy::Ann { .. } | CandidateStrategy::Scan { .. } | CandidateStrategy::SignalRanked { .. } => {} other => { return Err(QueryError::UnsupportedStrategy( format!("{other:?} requires M3+ infrastructure"), )); } } Ok(()) } } ``` ## Test Strategy ### Unit Tests ```rust // === RetrieveBuilder tests === #[test] fn builder_default_limit_50() { let query = Retrieve::builder() .profile("trending") .build() .unwrap(); assert_eq!(query.limit, 50); assert_eq!(query.entity_kind, EntityKind::Item); assert!(query.filters.is_empty()); assert!(query.diversity.is_none()); assert!(query.exclude.is_empty()); assert!(query.cursor.is_none()); } #[test] fn builder_with_all_fields() { let query = Retrieve::builder() .entity(EntityKind::Item) .profile("hot") .filter(FilterExpr::eq("category", "jazz")) .filter(FilterExpr::eq("format", "video")) .diversity(DiversityConstraints::new().max_per_creator(2)) .limit(25) .exclude(EntityId::new(999)) .build() .unwrap(); assert_eq!(query.entity_kind, EntityKind::Item); assert_eq!(query.profile.name, "hot"); assert_eq!(query.filters.len(), 2); assert!(query.diversity.is_some()); assert_eq!(query.limit, 25); assert_eq!(query.exclude.len(), 1); } #[test] fn builder_rejects_zero_limit() { let result = Retrieve::builder() .profile("trending") .limit(0) .build(); assert!(matches!(result, Err(QueryError::InvalidLimit { .. }))); } #[test] fn builder_rejects_limit_over_500() { let result = Retrieve::builder() .profile("trending") .limit(501) .build(); assert!(matches!(result, Err(QueryError::InvalidLimit { .. }))); } #[test] fn builder_rejects_missing_profile() { let result = Retrieve::builder() .entity(EntityKind::Item) .limit(25) .build(); assert!(matches!(result, Err(QueryError::ProfileNotFound(_)))); } #[test] fn builder_limit_boundary_values() { // Min valid let r1 = Retrieve::builder().profile("a").limit(1).build(); assert!(r1.is_ok()); assert_eq!(r1.unwrap().limit, 1); // Max valid let r2 = Retrieve::builder().profile("a").limit(500).build(); assert!(r2.is_ok()); assert_eq!(r2.unwrap().limit, 500); } #[test] fn builder_profile_versioned() { let query = Retrieve::builder() .profile_versioned("trending", 3) .build() .unwrap(); assert_eq!(query.profile.name, "trending"); assert_eq!(query.profile.version, Some(3)); } #[test] fn builder_multiple_excludes() { let query = Retrieve::builder() .profile("new") .exclude(EntityId::new(1)) .exclude(EntityId::new(2)) .exclude_ids(vec![EntityId::new(3), EntityId::new(4)]) .build() .unwrap(); assert_eq!(query.exclude.len(), 4); } #[test] fn combined_filter_none_when_empty() { let query = Retrieve::builder().profile("new").build().unwrap(); assert!(query.combined_filter().is_none()); } #[test] fn combined_filter_single() { let query = Retrieve::builder() .profile("new") .filter(FilterExpr::eq("category", "jazz")) .build() .unwrap(); let combined = query.combined_filter().unwrap(); assert!(matches!(combined, FilterExpr::Eq { .. })); } #[test] fn combined_filter_multiple_becomes_and() { let query = Retrieve::builder() .profile("new") .filter(FilterExpr::eq("category", "jazz")) .filter(FilterExpr::eq("format", "video")) .build() .unwrap(); let combined = query.combined_filter().unwrap(); assert!(matches!(combined, FilterExpr::And(_))); } // === Cursor tests === #[test] fn cursor_encode_decode_roundtrip() { let cursor = Cursor::from_offset(42); let encoded = cursor.encode(); let decoded = Cursor::decode(&encoded).unwrap(); assert_eq!(cursor, decoded); } #[test] fn cursor_encode_decode_zero() { let cursor = Cursor::from_offset(0); let encoded = cursor.encode(); let decoded = Cursor::decode(&encoded).unwrap(); assert_eq!(cursor, decoded); } #[test] fn cursor_encode_decode_large_offset() { let cursor = Cursor::from_offset(100_000); let encoded = cursor.encode(); let decoded = Cursor::decode(&encoded).unwrap(); assert_eq!(cursor, decoded); } #[test] fn cursor_decode_invalid_base64() { let result = Cursor::decode("!!!not-base64!!!"); assert!(matches!(result, Err(QueryError::InvalidCursor(_)))); } #[test] fn cursor_decode_wrong_length() { use base64::Engine as _; let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&[1u8, 2, 3]); let result = Cursor::decode(&encoded); assert!(matches!(result, Err(QueryError::InvalidCursor(_)))); } // === QueryError display tests === #[test] fn query_error_display_messages() { let e1 = QueryError::ProfileNotFound("trending".to_string()); assert!(e1.to_string().contains("trending")); let e2 = QueryError::InvalidLimit { requested: 0, min: 1, max: 500, }; assert!(e2.to_string().contains("0")); assert!(e2.to_string().contains("500")); let e3 = QueryError::InvalidFilter { field: "category".to_string(), reason: "unknown field".to_string(), }; assert!(e3.to_string().contains("category")); let e4 = QueryError::InvalidCursor("bad cursor".to_string()); assert!(e4.to_string().contains("bad cursor")); } // === RetrieveResult tests === #[test] fn results_len_and_is_empty() { let empty = Results { items: vec![], next_cursor: None, total_scored: 0, constraints_satisfied: true, warnings: vec![], }; assert_eq!(empty.len(), 0); assert!(empty.is_empty()); let one = Results { items: vec![RetrieveResult { entity_id: EntityId::new(1), score: 0.5, rank: 1, signal_snapshot: vec![], }], next_cursor: None, total_scored: 1, constraints_satisfied: true, warnings: vec![], }; assert_eq!(one.len(), 1); assert!(!one.is_empty()); } // === Signal struct tests === #[test] fn signal_new() { let sig = Signal::new( "view", EntityId::new(42), 1.0, Timestamp::from_nanos(1_000_000), ); assert_eq!(sig.signal_type, "view"); assert_eq!(sig.entity_id, EntityId::new(42)); assert!((sig.weight - 1.0).abs() < f64::EPSILON); } ``` ### Property Tests ```rust use proptest::prelude::*; // P1: Cursor encode/decode is lossless for all valid offsets. proptest! { #[test] fn cursor_roundtrip(offset in 0usize..1_000_000) { let cursor = Cursor::from_offset(offset); let encoded = cursor.encode(); let decoded = Cursor::decode(&encoded).unwrap(); prop_assert_eq!(cursor, decoded); } } // P2: Builder always produces valid Retrieve when required fields are set. proptest! { #[test] fn builder_valid_with_limit(limit in 1usize..=500) { let result = Retrieve::builder() .profile("test_profile") .limit(limit) .build(); prop_assert!(result.is_ok()); prop_assert_eq!(result.unwrap().limit, limit); } } // P3: Builder rejects invalid limits. proptest! { #[test] fn builder_rejects_invalid_limit(limit in 501usize..10_000) { let result = Retrieve::builder() .profile("test_profile") .limit(limit) .build(); prop_assert!(matches!(result, Err(QueryError::InvalidLimit { .. }))); } } ``` ## Acceptance Criteria - [ ] `Retrieve` struct with all fields: `entity_kind`, `profile`, `filters`, `diversity`, `limit`, `exclude`, `cursor`, `for_user`, `similar_to`, `context` - [ ] `RetrieveBuilder` with methods: `entity()`, `profile()`, `profile_versioned()`, `filter()`, `diversity()`, `limit()`, `exclude()`, `exclude_ids()`, `cursor()`, `build()` - [ ] `RetrieveBuilder::build()` validates: limit in [1, 500], profile present, `for_user` and `similar_to` rejected in M2 - [ ] `Retrieve::combined_filter()` returns `None` for empty filters, single filter as-is, AND for multiple - [ ] `ProfileRef` with `new()` (latest version) and `versioned()` (pinned version) - [ ] `Results` struct with `items`, `next_cursor`, `total_scored`, `constraints_satisfied`, `warnings`, `len()`, `is_empty()` - [ ] `RetrieveResult` struct with `entity_id`, `score`, `rank`, `signal_snapshot` - [ ] `Cursor` with `from_offset()`, `offset()`, `encode()`, `decode()` -- base64 roundtrip is lossless - [ ] `Cursor::decode()` returns `QueryError::InvalidCursor` for invalid input - [ ] `Signal` struct with `new()` constructor and all fields - [ ] `QueryError` enum with `ProfileNotFound`, `InvalidFilter`, `InvalidLimit`, `IndexNotAvailable`, `StorageError`, `InvalidCursor`, `UnsupportedStrategy` - [ ] `QueryError` implements `Display` and `Error` - [ ] `Retrieve::validate()` checks profile existence against `ProfileRegistry`, rejects unsupported candidate strategies - [ ] Property test: cursor roundtrip for all valid offsets - [ ] Property test: builder accepts valid limits [1, 500], rejects [501, ...] - [ ] No `unsafe` code - [ ] `cargo clippy -- -D warnings` passes - [ ] All unit tests and property tests pass ## Research References - [docs/specs/08-query-engine.md](../../../specs/08-query-engine.md) -- Section 3.1 (`Retrieve` struct fields), Section 3.4 (`QueryError` enum variants and validation rules), Section 8.2 (`Cursor` structure and encoding) ## Spec References - [docs/specs/08-query-engine.md](../../../specs/08-query-engine.md) -- Section 2.1 (RETRIEVE operation overview), Section 3.1 (Retrieve input struct), Section 3.4 (QueryError enum), Section 8 (Pagination: cursor design, encoding, semantics) ## Implementation Notes - Add `base64 = "0.22"` to `[dependencies]` in `tidal/Cargo.toml`. The `base64` crate is small (no transitive deps beyond `std`) and provides the `URL_SAFE_NO_PAD` engine for cursor encoding. - The `query/mod.rs` file should be created with `pub mod retrieve;` and re-exports of all public types. The `pub mod executor;` line is added in Task 02 when the executor module is created. - `lib.rs` should add `pub mod query;` -- this is the first time the query module exists. - `FilterExpr` is imported from `crate::storage::indexes::filter` (m2p2). If the exact import path differs from the m2p2 implementation, adapt accordingly. - `DiversityConstraints` is imported from `crate::ranking::diversity` (m2p4). The `DiversityConstraints::new()` and `.max_per_creator()` API must match the m2p4 implementation. - The `for_user: Option` field uses `u64` rather than `UserId` because `UserId` (a newtype over `u64`) is not defined until M3 when user entities are introduced. In M3, this field will be changed to `Option`. - Do NOT add `serde` derives to the query types for M2. Serialization of query types is an M5+ concern when the text parser and network protocol are built. ## Migration from M1 QueryError Stub **This task must remove the M1 stub `QueryError` before adding the new one.** A stub `QueryError` struct exists in `tidal/src/schema/error.rs` (simple `{ message: String }` struct) and is re-exported through `schema/mod.rs` and wrapped by `LumenError::Query`. The M2 `QueryError` is a rich enum that replaces it. Migration steps (in order): 1. Remove the stub `QueryError` struct from `tidal/src/schema/error.rs` 2. Add `pub use crate::query::retrieve::QueryError;` in `tidal/src/schema/mod.rs` (or update the `From` impl in `LumenError` to use the new path) 3. Update `LumenError::Query` variant to hold `crate::query::retrieve::QueryError` 4. Update the `From for LumenError` impl to use the new enum 5. Fix any existing tests in `schema/error.rs` that construct the old `QueryError { message: "..." }` struct Do NOT create `query/retrieve.rs` before completing steps 1-4 -- the name collision will cause confusing compilation errors. ## Intentional Spec Deviations The following fields differ from Spec 08 Section 3.1's `Retrieve` struct definition. These are intentional improvements: | This task | Spec 08 | Reason | |-----------|---------|--------| | `entity_kind: EntityKind` | `entity: EntityKind` | More explicit — avoids confusion with `entity_id` | | `exclude: Vec` | `exclude_ids: Vec` | Shorter; builder method `.exclude()` reads naturally | | `profile: ProfileRef` | `profile: String` | Richer type; supports version pinning for A/B testing | | `diversity: Option` | `diversity: Option` | Matches m2p4's concrete type name | These deviations are safe: the spec defines behavior, not names. Future language parsing (M5) maps text tokens to these struct fields.