Skip to main content

triplets_core/
data.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::kvp::KvpPrefixSampler;
6
7pub use crate::types::{RecordId, Sentence, SourceId, TaxonomyValue};
8
9/// Trust/quality metadata for a record.
10#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
11pub struct QualityScore {
12    /// Normalized 0-1 trust measure combining provenance, recency, and manual reviews.
13    pub trust: f32,
14}
15
16impl Default for QualityScore {
17    fn default() -> Self {
18        Self {
19            // Assume medium trust by default, allowing recipes to upweight or downweight based on other signals.
20            trust: 0.5,
21        }
22    }
23}
24
25/// Canonical record payload produced by a DataSource.
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct DataRecord {
28    /// Stable record identifier (used for splits and determinism).
29    pub id: RecordId,
30    /// Source identifier that produced this record.
31    pub source: SourceId,
32    /// Canonical creation time for the record (used for ordering/metadata).
33    pub created_at: DateTime<Utc>,
34    /// Last update time for the record (used for refresh decisions).
35    pub updated_at: DateTime<Utc>,
36    /// Trust/quality score used to weight sampling.
37    pub quality: QualityScore,
38    /// Free-form tags (e.g., source id, year, date) used for filtering/recipes.
39    pub taxonomy: Vec<TaxonomyValue>,
40    /// Structured content sections used by sampling recipes.
41    pub sections: Vec<RecordSection>,
42    /// Optional metadata prefix policy for KVP sampling (key-value headers injected into text).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub meta_prefix: Option<KvpPrefixSampler>,
45    /// Pair label for supervised pair entries (Positive/Negative).
46    ///
47    /// Only meaningful for pair-mode records written by the SRD pipeline.
48    /// `None` for records from other sources or triplet-mode entries.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub label: Option<PairLabel>,
51}
52
53impl DataRecord {
54    /// Create a record with a single [`SectionRole::Context`] section from a plain text string.
55    ///
56    /// The `id` and `source` are set to the same value. Use [`DataRecord::from_text_with_role`]
57    /// to assign a different role, or construct the struct directly for full control.
58    ///
59    /// # Example
60    ///
61    /// ```
62    /// use triplets_core::DataRecord;
63    ///
64    /// let record = DataRecord::from_text("doc-0", "my_corpus", "The quick brown fox.");
65    /// assert_eq!(record.id.as_str(), "doc-0");
66    /// assert_eq!(record.sections[0].text, "The quick brown fox.");
67    /// ```
68    pub fn from_text(
69        id: impl Into<crate::types::RecordId>,
70        source: impl Into<crate::types::SourceId>,
71        text: impl Into<String>,
72    ) -> Self {
73        Self::from_text_with_role(id, source, text, SectionRole::Context)
74    }
75
76    /// Create a record with a single section of the given role from a plain text string.
77    ///
78    /// # Example
79    ///
80    /// ```
81    /// use triplets_core::{DataRecord, SectionRole};
82    ///
83    /// let record = DataRecord::from_text_with_role(
84    ///     "doc-0", "my_corpus", "What is the capital of France?", SectionRole::Anchor,
85    /// );
86    /// assert_eq!(record.sections[0].role, SectionRole::Anchor);
87    /// ```
88    pub fn from_text_with_role(
89        id: impl Into<crate::types::RecordId>,
90        source: impl Into<crate::types::SourceId>,
91        text: impl Into<String>,
92        role: SectionRole,
93    ) -> Self {
94        use crate::tokenizer::{Tokenizer, WhitespaceTokenizer};
95        let now = chrono::Utc::now();
96        let text_str = text.into();
97        let token_count = WhitespaceTokenizer.token_count(&text_str);
98        Self {
99            id: id.into(),
100            source: source.into(),
101            created_at: now,
102            updated_at: now,
103            quality: QualityScore::default(),
104            taxonomy: vec![],
105            sections: vec![RecordSection {
106                role,
107                heading: None,
108                text: text_str,
109                sentences: vec![],
110                token_count,
111            }],
112            meta_prefix: None,
113            label: None,
114        }
115    }
116}
117
118/// A structured section within a record.
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct RecordSection {
121    /// Semantic role used by selectors (for example, anchor vs context text).
122    pub role: SectionRole,
123    /// Optional short heading/title for this section.
124    pub heading: Option<String>,
125    /// Full section text.
126    pub text: String,
127    /// Sentence-level segmentation of `text` used by chunking strategies.
128    pub sentences: Vec<Sentence>,
129    /// Precomputed token count for this section, populated during section construction.
130    #[serde(default)]
131    pub token_count: usize,
132}
133
134impl Default for RecordSection {
135    fn default() -> Self {
136        Self {
137            role: SectionRole::Context,
138            heading: None,
139            text: String::new(),
140            sentences: Vec::new(),
141            token_count: 0,
142        }
143    }
144}
145
146/// Role label for a section.
147#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
148pub enum SectionRole {
149    /// Primary section typically used as an anchor candidate.
150    Anchor,
151    /// Supporting/context section used for positives, negatives, or text samples.
152    Context,
153}
154
155/// A chunked view over a section.
156#[derive(Clone, Debug, Serialize, Deserialize)]
157pub struct RecordChunk {
158    /// Parent record id this chunk belongs to.
159    pub record_id: RecordId,
160    /// Index of the source section in `DataRecord.sections`.
161    pub section_idx: usize,
162    /// Chunk view metadata (window position or summary fallback).
163    pub view: ChunkView,
164    /// Rendered chunk text (possibly with metadata prefix decoration).
165    pub text: String,
166    /// Approximate token count for scheduling/weighting heuristics.
167    pub tokens_estimate: usize,
168    /// Trust/quality inherited from the parent record.
169    pub quality: QualityScore,
170    /// All KVP metadata defined on the source record's `meta_prefix`, exposed for
171    /// downstream inspection and debugging. Contains every key with all its possible
172    /// values across all variants — unaffected by presence probability, dropout, or
173    /// which variant was sampled into this chunk's text.
174    ///
175    /// Populated unconditionally by the sampler during chunk decoration. Empty when the
176    /// record has no `meta_prefix` configured.
177    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
178    pub kvp_meta: HashMap<String, Vec<String>>,
179}
180
181/// Chunk view metadata (window or summary).
182#[derive(Clone, Debug, Serialize, Deserialize)]
183pub enum ChunkView {
184    /// Sliding-window chunk extracted directly from section text.
185    Window {
186        /// Zero-based window index within the section.
187        index: usize,
188        /// Overlap (in tokens) with the previous window.
189        overlap: usize,
190        /// Nominal window span in tokens.
191        span: usize,
192    },
193    /// Summary fallback chunk used when window extraction is unavailable.
194    SummaryFallback {
195        /// Name of summary strategy that produced this fallback chunk.
196        strategy: String,
197        /// Precomputed base weight for summary-fallback chunks before trust/floor are applied.
198        weight: f32,
199    },
200}
201
202/// Sample pair (positive/negative) derived from a triplet.
203#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct SamplePair {
205    /// Recipe name used to generate this pair.
206    pub recipe: String,
207    /// Anchor chunk used to build this supervised pair.
208    pub anchor: RecordChunk,
209    /// Candidate chunk paired with the anchor.
210    pub positive: RecordChunk,
211    /// Training weight for this pair.
212    pub weight: f32,
213    /// Optional instruction/prompt hint for this sample.
214    pub instruction: Option<String>,
215    /// Supervision label (positive or negative).
216    pub label: PairLabel,
217    /// Optional reason/annotation describing the label.
218    pub reason: Option<String>,
219}
220
221/// Sample triplet (anchor/positive/negative).
222#[derive(Clone, Debug, Serialize, Deserialize)]
223pub struct SampleTriplet {
224    /// Recipe name used to generate this triplet.
225    pub recipe: String,
226    /// Anchor chunk.
227    pub anchor: RecordChunk,
228    /// Positive chunk.
229    pub positive: RecordChunk,
230    /// Negative chunk.
231    pub negative: RecordChunk,
232    /// Training weight for this triplet.
233    pub weight: f32,
234    /// Optional instruction/prompt hint for this sample.
235    pub instruction: Option<String>,
236}
237
238/// Pair label for supervised pair batches.
239#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
240pub enum PairLabel {
241    /// Anchor and candidate are semantically aligned.
242    Positive,
243    /// Anchor and candidate are semantically mismatched.
244    Negative,
245}
246
247/// Batch of pairs.
248#[derive(Clone, Debug, Serialize, Deserialize)]
249pub struct SampleBatch {
250    /// Pair samples contained in this batch.
251    pub pairs: Vec<SamplePair>,
252}
253
254impl SampleBatch {
255    /// Returns `true` when the batch has no pairs.
256    pub fn is_empty(&self) -> bool {
257        self.pairs.is_empty()
258    }
259}
260
261/// Batch of triplets.
262#[derive(Clone, Debug, Serialize, Deserialize)]
263pub struct TripletBatch {
264    /// Triplet samples contained in this batch.
265    pub triplets: Vec<SampleTriplet>,
266}
267
268impl TripletBatch {
269    /// Returns `true` when the batch has no triplets.
270    pub fn is_empty(&self) -> bool {
271        self.triplets.is_empty()
272    }
273}
274
275/// A single text sample (chunk + weight).
276#[derive(Clone, Debug, Serialize, Deserialize)]
277pub struct TextSample {
278    /// Recipe name used to generate this sample.
279    pub recipe: String,
280    /// Chunk payload used for this text sample.
281    pub chunk: RecordChunk,
282    /// Training weight for this sample.
283    pub weight: f32,
284    /// Optional instruction/prompt hint for this sample.
285    pub instruction: Option<String>,
286}
287
288/// Batch of text samples.
289#[derive(Clone, Debug, Serialize, Deserialize)]
290pub struct TextBatch {
291    /// Text samples contained in this batch.
292    pub samples: Vec<TextSample>,
293}
294
295impl TextBatch {
296    /// Returns `true` when the batch has no text samples.
297    pub fn is_empty(&self) -> bool {
298        self.samples.is_empty()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use chrono::{TimeZone, Utc};
306
307    fn sample_chunk(id: &str) -> RecordChunk {
308        RecordChunk {
309            record_id: id.to_string(),
310            section_idx: 0,
311            view: ChunkView::SummaryFallback {
312                strategy: "test".to_string(),
313                weight: 1.0,
314            },
315            text: "text".to_string(),
316            tokens_estimate: 4,
317            quality: QualityScore::default(),
318            kvp_meta: Default::default(),
319        }
320    }
321
322    #[test]
323    fn quality_score_defaults_to_medium_trust() {
324        let quality = QualityScore::default();
325        assert!((quality.trust - 0.5).abs() < f32::EPSILON);
326    }
327
328    #[test]
329    fn batch_is_empty_helpers_match_contents() {
330        let empty_pairs = SampleBatch { pairs: Vec::new() };
331        assert!(empty_pairs.is_empty());
332
333        let non_empty_pairs = SampleBatch {
334            pairs: vec![SamplePair {
335                recipe: "r".to_string(),
336                anchor: sample_chunk("a"),
337                positive: sample_chunk("b"),
338                weight: 1.0,
339                instruction: None,
340                label: PairLabel::Positive,
341                reason: Some("test".to_string()),
342            }],
343        };
344        assert!(!non_empty_pairs.is_empty());
345
346        let empty_triplets = TripletBatch {
347            triplets: Vec::new(),
348        };
349        assert!(empty_triplets.is_empty());
350
351        let non_empty_triplets = TripletBatch {
352            triplets: vec![SampleTriplet {
353                recipe: "r".to_string(),
354                anchor: sample_chunk("a"),
355                positive: sample_chunk("b"),
356                negative: sample_chunk("c"),
357                weight: 1.0,
358                instruction: Some("hint".to_string()),
359            }],
360        };
361        assert!(!non_empty_triplets.is_empty());
362
363        let empty_text = TextBatch {
364            samples: Vec::new(),
365        };
366        assert!(empty_text.is_empty());
367
368        let non_empty_text = TextBatch {
369            samples: vec![TextSample {
370                recipe: "r".to_string(),
371                chunk: sample_chunk("t"),
372                weight: 1.0,
373                instruction: None,
374            }],
375        };
376        assert!(!non_empty_text.is_empty());
377    }
378
379    #[test]
380    fn data_record_roundtrip_basics_are_constructible() {
381        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
382        let record = DataRecord {
383            id: "source_a::1".to_string(),
384            source: "source_a".to_string(),
385            created_at: now,
386            updated_at: now,
387            quality: QualityScore { trust: 0.9 },
388            taxonomy: vec!["topic:news".to_string()],
389            sections: vec![RecordSection {
390                role: SectionRole::Anchor,
391                heading: Some("headline".to_string()),
392                text: "body".to_string(),
393                sentences: vec!["body".to_string()],
394                token_count: 0,
395            }],
396            meta_prefix: None,
397            label: None,
398        };
399
400        assert_eq!(record.source, "source_a");
401        assert_eq!(record.sections.len(), 1);
402        assert!(matches!(record.sections[0].role, SectionRole::Anchor));
403    }
404
405    #[test]
406    fn from_text_creates_record_with_context_role() {
407        let record = DataRecord::from_text("id1", "src1", "hello world");
408        assert_eq!(record.id.as_str(), "id1");
409        assert_eq!(record.source.as_str(), "src1");
410        assert_eq!(record.sections.len(), 1);
411        assert_eq!(record.sections[0].text, "hello world");
412        assert!(matches!(record.sections[0].role, SectionRole::Context));
413    }
414
415    #[test]
416    fn from_text_with_role_creates_record_with_specified_role() {
417        let record =
418            DataRecord::from_text_with_role("id1", "src1", "anchor text", SectionRole::Anchor);
419        assert!(matches!(record.sections[0].role, SectionRole::Anchor));
420        assert_eq!(record.sections[0].text, "anchor text");
421    }
422
423    #[test]
424    fn data_record_with_label() {
425        let record = DataRecord {
426            id: "id".into(),
427            source: "src".into(),
428            created_at: Utc::now(),
429            updated_at: Utc::now(),
430            quality: QualityScore::default(),
431            taxonomy: vec![],
432            sections: vec![],
433            meta_prefix: None,
434            label: Some(PairLabel::Positive),
435        };
436        assert_eq!(record.label, Some(PairLabel::Positive));
437    }
438
439    #[test]
440    fn data_record_with_taxonomy() {
441        let record = DataRecord {
442            id: "id".into(),
443            source: "src".into(),
444            created_at: Utc::now(),
445            updated_at: Utc::now(),
446            quality: QualityScore::default(),
447            taxonomy: vec!["year:2025".into(), "topic:ai".into()],
448            sections: vec![],
449            meta_prefix: None,
450            label: None,
451        };
452        assert_eq!(record.taxonomy.len(), 2);
453    }
454
455    #[test]
456    fn pair_label_variants() {
457        let pos = PairLabel::Positive;
458        let neg = PairLabel::Negative;
459        assert_ne!(pos, neg);
460        assert_eq!(pos, PairLabel::Positive);
461        assert_eq!(neg, PairLabel::Negative);
462    }
463
464    #[test]
465    fn chunk_view_variants() {
466        let window = ChunkView::Window {
467            index: 0,
468            overlap: 10,
469            span: 100,
470        };
471        let summary = ChunkView::SummaryFallback {
472            strategy: "head".into(),
473            weight: 0.5,
474        };
475        assert!(matches!(window, ChunkView::Window { .. }));
476        assert!(matches!(summary, ChunkView::SummaryFallback { .. }));
477    }
478
479    #[test]
480    fn record_section_with_heading() {
481        let section = RecordSection {
482            role: SectionRole::Context,
483            heading: Some("Title".into()),
484            text: "content".into(),
485            sentences: vec!["content".into()],
486            token_count: 0,
487        };
488        assert_eq!(section.heading, Some("Title".to_string()));
489    }
490
491    #[test]
492    fn sample_pair_with_reason() {
493        let pair = SamplePair {
494            recipe: "test".into(),
495            anchor: sample_chunk("a"),
496            positive: sample_chunk("b"),
497            weight: 1.5,
498            instruction: Some("instruction".into()),
499            label: PairLabel::Negative,
500            reason: Some("wrong topic".into()),
501        };
502        assert_eq!(pair.weight, 1.5);
503        assert_eq!(pair.reason, Some("wrong topic".to_string()));
504    }
505
506    #[test]
507    fn sample_triplet_construction() {
508        let triplet = SampleTriplet {
509            recipe: "triplet_recipe".into(),
510            anchor: sample_chunk("a"),
511            positive: sample_chunk("p"),
512            negative: sample_chunk("n"),
513            weight: 2.0,
514            instruction: None,
515        };
516        assert_eq!(triplet.recipe, "triplet_recipe");
517        assert_eq!(triplet.weight, 2.0);
518    }
519
520    #[test]
521    fn text_sample_construction() {
522        let sample = TextSample {
523            recipe: "text_recipe".into(),
524            chunk: sample_chunk("t"),
525            weight: 1.0,
526            instruction: Some("do this".into()),
527        };
528        assert_eq!(sample.recipe, "text_recipe");
529        assert!(sample.instruction.is_some());
530    }
531
532    #[test]
533    fn record_section_default_has_zero_token_count_and_empty_text() {
534        let section = RecordSection::default();
535        assert_eq!(section.token_count, 0);
536        assert!(section.text.is_empty());
537        assert!(section.heading.is_none());
538        assert!(section.sentences.is_empty());
539        assert!(matches!(section.role, SectionRole::Context));
540    }
541
542    #[test]
543    fn from_text_with_role_populates_token_count() {
544        use crate::tokenizer::{Tokenizer, WhitespaceTokenizer};
545        let record = DataRecord::from_text_with_role(
546            "tok-test",
547            "src",
548            "one two three",
549            SectionRole::Anchor,
550        );
551        let expected = WhitespaceTokenizer.token_count("one two three");
552        assert_eq!(record.sections[0].token_count, expected);
553        assert!(record.meta_prefix.is_none());
554        assert!(record.label.is_none());
555    }
556
557    #[test]
558    fn from_text_sets_context_role_and_computes_token_count() {
559        use crate::tokenizer::{Tokenizer, WhitespaceTokenizer};
560        let record = DataRecord::from_text("from-txt", "src", "hello beautiful world");
561        assert!(matches!(record.sections[0].role, SectionRole::Context));
562        assert_eq!(
563            record.sections[0].token_count,
564            WhitespaceTokenizer.token_count("hello beautiful world")
565        );
566        assert_eq!(record.id.as_str(), "from-txt");
567    }
568}