Skip to main content

triplets_core/
data.rs

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