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        let now = chrono::Utc::now();
95        Self {
96            id: id.into(),
97            source: source.into(),
98            created_at: now,
99            updated_at: now,
100            quality: QualityScore::default(),
101            taxonomy: vec![],
102            sections: vec![RecordSection {
103                role,
104                heading: None,
105                text: text.into(),
106                sentences: vec![],
107            }],
108            meta_prefix: None,
109            label: None,
110        }
111    }
112}
113
114/// A structured section within a record.
115#[derive(Clone, Debug, Serialize, Deserialize)]
116pub struct RecordSection {
117    /// Semantic role used by selectors (for example, anchor vs context text).
118    pub role: SectionRole,
119    /// Optional short heading/title for this section.
120    pub heading: Option<String>,
121    /// Full section text.
122    pub text: String,
123    /// Sentence-level segmentation of `text` used by chunking strategies.
124    pub sentences: Vec<Sentence>,
125}
126
127/// Role label for a section.
128#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
129pub enum SectionRole {
130    /// Primary section typically used as an anchor candidate.
131    Anchor,
132    /// Supporting/context section used for positives, negatives, or text samples.
133    Context,
134}
135
136/// A chunked view over a section.
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct RecordChunk {
139    /// Parent record id this chunk belongs to.
140    pub record_id: RecordId,
141    /// Index of the source section in `DataRecord.sections`.
142    pub section_idx: usize,
143    /// Chunk view metadata (window position or summary fallback).
144    pub view: ChunkView,
145    /// Rendered chunk text (possibly with metadata prefix decoration).
146    pub text: String,
147    /// Approximate token count for scheduling/weighting heuristics.
148    pub tokens_estimate: usize,
149    /// Trust/quality inherited from the parent record.
150    pub quality: QualityScore,
151    /// All KVP metadata defined on the source record's `meta_prefix`, exposed for
152    /// downstream inspection and debugging. Contains every key with all its possible
153    /// values across all variants — unaffected by presence probability, dropout, or
154    /// which variant was sampled into this chunk's text.
155    ///
156    /// Populated unconditionally by the sampler during chunk decoration. Empty when the
157    /// record has no `meta_prefix` configured.
158    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
159    pub kvp_meta: HashMap<String, Vec<String>>,
160}
161
162/// Chunk view metadata (window or summary).
163#[derive(Clone, Debug, Serialize, Deserialize)]
164pub enum ChunkView {
165    /// Sliding-window chunk extracted directly from section text.
166    Window {
167        /// Zero-based window index within the section.
168        index: usize,
169        /// Overlap (in tokens) with the previous window.
170        overlap: usize,
171        /// Nominal window span in tokens.
172        span: usize,
173    },
174    /// Summary fallback chunk used when window extraction is unavailable.
175    SummaryFallback {
176        /// Name of summary strategy that produced this fallback chunk.
177        strategy: String,
178        /// Precomputed base weight for summary-fallback chunks before trust/floor are applied.
179        weight: f32,
180    },
181}
182
183/// Sample pair (positive/negative) derived from a triplet.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct SamplePair {
186    /// Recipe name used to generate this pair.
187    pub recipe: String,
188    /// Anchor chunk used to build this supervised pair.
189    pub anchor: RecordChunk,
190    /// Candidate chunk paired with the anchor.
191    pub positive: RecordChunk,
192    /// Training weight for this pair.
193    pub weight: f32,
194    /// Optional instruction/prompt hint for this sample.
195    pub instruction: Option<String>,
196    /// Supervision label (positive or negative).
197    pub label: PairLabel,
198    /// Optional reason/annotation describing the label.
199    pub reason: Option<String>,
200}
201
202/// Sample triplet (anchor/positive/negative).
203#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct SampleTriplet {
205    /// Recipe name used to generate this triplet.
206    pub recipe: String,
207    /// Anchor chunk.
208    pub anchor: RecordChunk,
209    /// Positive chunk.
210    pub positive: RecordChunk,
211    /// Negative chunk.
212    pub negative: RecordChunk,
213    /// Training weight for this triplet.
214    pub weight: f32,
215    /// Optional instruction/prompt hint for this sample.
216    pub instruction: Option<String>,
217}
218
219/// Pair label for supervised pair batches.
220#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
221pub enum PairLabel {
222    /// Anchor and candidate are semantically aligned.
223    Positive,
224    /// Anchor and candidate are semantically mismatched.
225    Negative,
226}
227
228/// Batch of pairs.
229#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct SampleBatch {
231    /// Pair samples contained in this batch.
232    pub pairs: Vec<SamplePair>,
233}
234
235impl SampleBatch {
236    /// Returns `true` when the batch has no pairs.
237    pub fn is_empty(&self) -> bool {
238        self.pairs.is_empty()
239    }
240}
241
242/// Batch of triplets.
243#[derive(Clone, Debug, Serialize, Deserialize)]
244pub struct TripletBatch {
245    /// Triplet samples contained in this batch.
246    pub triplets: Vec<SampleTriplet>,
247}
248
249impl TripletBatch {
250    /// Returns `true` when the batch has no triplets.
251    pub fn is_empty(&self) -> bool {
252        self.triplets.is_empty()
253    }
254}
255
256/// A single text sample (chunk + weight).
257#[derive(Clone, Debug, Serialize, Deserialize)]
258pub struct TextSample {
259    /// Recipe name used to generate this sample.
260    pub recipe: String,
261    /// Chunk payload used for this text sample.
262    pub chunk: RecordChunk,
263    /// Training weight for this sample.
264    pub weight: f32,
265    /// Optional instruction/prompt hint for this sample.
266    pub instruction: Option<String>,
267}
268
269/// Batch of text samples.
270#[derive(Clone, Debug, Serialize, Deserialize)]
271pub struct TextBatch {
272    /// Text samples contained in this batch.
273    pub samples: Vec<TextSample>,
274}
275
276impl TextBatch {
277    /// Returns `true` when the batch has no text samples.
278    pub fn is_empty(&self) -> bool {
279        self.samples.is_empty()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use chrono::{TimeZone, Utc};
287
288    fn sample_chunk(id: &str) -> RecordChunk {
289        RecordChunk {
290            record_id: id.to_string(),
291            section_idx: 0,
292            view: ChunkView::SummaryFallback {
293                strategy: "test".to_string(),
294                weight: 1.0,
295            },
296            text: "text".to_string(),
297            tokens_estimate: 4,
298            quality: QualityScore::default(),
299            kvp_meta: Default::default(),
300        }
301    }
302
303    #[test]
304    fn quality_score_defaults_to_medium_trust() {
305        let quality = QualityScore::default();
306        assert!((quality.trust - 0.5).abs() < f32::EPSILON);
307    }
308
309    #[test]
310    fn batch_is_empty_helpers_match_contents() {
311        let empty_pairs = SampleBatch { pairs: Vec::new() };
312        assert!(empty_pairs.is_empty());
313
314        let non_empty_pairs = SampleBatch {
315            pairs: vec![SamplePair {
316                recipe: "r".to_string(),
317                anchor: sample_chunk("a"),
318                positive: sample_chunk("b"),
319                weight: 1.0,
320                instruction: None,
321                label: PairLabel::Positive,
322                reason: Some("test".to_string()),
323            }],
324        };
325        assert!(!non_empty_pairs.is_empty());
326
327        let empty_triplets = TripletBatch {
328            triplets: Vec::new(),
329        };
330        assert!(empty_triplets.is_empty());
331
332        let non_empty_triplets = TripletBatch {
333            triplets: vec![SampleTriplet {
334                recipe: "r".to_string(),
335                anchor: sample_chunk("a"),
336                positive: sample_chunk("b"),
337                negative: sample_chunk("c"),
338                weight: 1.0,
339                instruction: Some("hint".to_string()),
340            }],
341        };
342        assert!(!non_empty_triplets.is_empty());
343
344        let empty_text = TextBatch {
345            samples: Vec::new(),
346        };
347        assert!(empty_text.is_empty());
348
349        let non_empty_text = TextBatch {
350            samples: vec![TextSample {
351                recipe: "r".to_string(),
352                chunk: sample_chunk("t"),
353                weight: 1.0,
354                instruction: None,
355            }],
356        };
357        assert!(!non_empty_text.is_empty());
358    }
359
360    #[test]
361    fn data_record_roundtrip_basics_are_constructible() {
362        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
363        let record = DataRecord {
364            id: "source_a::1".to_string(),
365            source: "source_a".to_string(),
366            created_at: now,
367            updated_at: now,
368            quality: QualityScore { trust: 0.9 },
369            taxonomy: vec!["topic:news".to_string()],
370            sections: vec![RecordSection {
371                role: SectionRole::Anchor,
372                heading: Some("headline".to_string()),
373                text: "body".to_string(),
374                sentences: vec!["body".to_string()],
375            }],
376            meta_prefix: None,
377            label: None,
378        };
379
380        assert_eq!(record.source, "source_a");
381        assert_eq!(record.sections.len(), 1);
382        assert!(matches!(record.sections[0].role, SectionRole::Anchor));
383    }
384
385    #[test]
386    fn from_text_creates_record_with_context_role() {
387        let record = DataRecord::from_text("id1", "src1", "hello world");
388        assert_eq!(record.id.as_str(), "id1");
389        assert_eq!(record.source.as_str(), "src1");
390        assert_eq!(record.sections.len(), 1);
391        assert_eq!(record.sections[0].text, "hello world");
392        assert!(matches!(record.sections[0].role, SectionRole::Context));
393    }
394
395    #[test]
396    fn from_text_with_role_creates_record_with_specified_role() {
397        let record =
398            DataRecord::from_text_with_role("id1", "src1", "anchor text", SectionRole::Anchor);
399        assert!(matches!(record.sections[0].role, SectionRole::Anchor));
400        assert_eq!(record.sections[0].text, "anchor text");
401    }
402
403    #[test]
404    fn data_record_with_label() {
405        let record = DataRecord {
406            id: "id".into(),
407            source: "src".into(),
408            created_at: Utc::now(),
409            updated_at: Utc::now(),
410            quality: QualityScore::default(),
411            taxonomy: vec![],
412            sections: vec![],
413            meta_prefix: None,
414            label: Some(PairLabel::Positive),
415        };
416        assert_eq!(record.label, Some(PairLabel::Positive));
417    }
418
419    #[test]
420    fn data_record_with_taxonomy() {
421        let record = DataRecord {
422            id: "id".into(),
423            source: "src".into(),
424            created_at: Utc::now(),
425            updated_at: Utc::now(),
426            quality: QualityScore::default(),
427            taxonomy: vec!["year:2025".into(), "topic:ai".into()],
428            sections: vec![],
429            meta_prefix: None,
430            label: None,
431        };
432        assert_eq!(record.taxonomy.len(), 2);
433    }
434
435    #[test]
436    fn pair_label_variants() {
437        let pos = PairLabel::Positive;
438        let neg = PairLabel::Negative;
439        assert_ne!(pos, neg);
440        assert_eq!(pos, PairLabel::Positive);
441        assert_eq!(neg, PairLabel::Negative);
442    }
443
444    #[test]
445    fn chunk_view_variants() {
446        let window = ChunkView::Window {
447            index: 0,
448            overlap: 10,
449            span: 100,
450        };
451        let summary = ChunkView::SummaryFallback {
452            strategy: "head".into(),
453            weight: 0.5,
454        };
455        assert!(matches!(window, ChunkView::Window { .. }));
456        assert!(matches!(summary, ChunkView::SummaryFallback { .. }));
457    }
458
459    #[test]
460    fn record_section_with_heading() {
461        let section = RecordSection {
462            role: SectionRole::Context,
463            heading: Some("Title".into()),
464            text: "content".into(),
465            sentences: vec!["content".into()],
466        };
467        assert_eq!(section.heading, Some("Title".to_string()));
468    }
469
470    #[test]
471    fn sample_pair_with_reason() {
472        let pair = SamplePair {
473            recipe: "test".into(),
474            anchor: sample_chunk("a"),
475            positive: sample_chunk("b"),
476            weight: 1.5,
477            instruction: Some("instruction".into()),
478            label: PairLabel::Negative,
479            reason: Some("wrong topic".into()),
480        };
481        assert_eq!(pair.weight, 1.5);
482        assert_eq!(pair.reason, Some("wrong topic".to_string()));
483    }
484
485    #[test]
486    fn sample_triplet_construction() {
487        let triplet = SampleTriplet {
488            recipe: "triplet_recipe".into(),
489            anchor: sample_chunk("a"),
490            positive: sample_chunk("p"),
491            negative: sample_chunk("n"),
492            weight: 2.0,
493            instruction: None,
494        };
495        assert_eq!(triplet.recipe, "triplet_recipe");
496        assert_eq!(triplet.weight, 2.0);
497    }
498
499    #[test]
500    fn text_sample_construction() {
501        let sample = TextSample {
502            recipe: "text_recipe".into(),
503            chunk: sample_chunk("t"),
504            weight: 1.0,
505            instruction: Some("do this".into()),
506        };
507        assert_eq!(sample.recipe, "text_recipe");
508        assert!(sample.instruction.is_some());
509    }
510}