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#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
11pub struct QualityScore {
12 pub trust: f32,
14}
15
16impl Default for QualityScore {
17 fn default() -> Self {
18 Self {
19 trust: 0.5,
21 }
22 }
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct DataRecord {
28 pub id: RecordId,
30 pub source: SourceId,
32 pub created_at: DateTime<Utc>,
34 pub updated_at: DateTime<Utc>,
36 pub quality: QualityScore,
38 pub taxonomy: Vec<TaxonomyValue>,
40 pub sections: Vec<RecordSection>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub meta_prefix: Option<KvpPrefixSampler>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub label: Option<PairLabel>,
51}
52
53impl DataRecord {
54 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
116pub struct RecordSection {
117 pub role: SectionRole,
119 pub heading: Option<String>,
121 pub text: String,
123 pub sentences: Vec<Sentence>,
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
129pub enum SectionRole {
130 Anchor,
132 Context,
134}
135
136#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct RecordChunk {
139 pub record_id: RecordId,
141 pub section_idx: usize,
143 pub view: ChunkView,
145 pub text: String,
147 pub tokens_estimate: usize,
149 pub quality: QualityScore,
151 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
159 pub kvp_meta: HashMap<String, Vec<String>>,
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
164pub enum ChunkView {
165 Window {
167 index: usize,
169 overlap: usize,
171 span: usize,
173 },
174 SummaryFallback {
176 strategy: String,
178 weight: f32,
180 },
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct SamplePair {
186 pub recipe: String,
188 pub anchor: RecordChunk,
190 pub positive: RecordChunk,
192 pub weight: f32,
194 pub instruction: Option<String>,
196 pub label: PairLabel,
198 pub reason: Option<String>,
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct SampleTriplet {
205 pub recipe: String,
207 pub anchor: RecordChunk,
209 pub positive: RecordChunk,
211 pub negative: RecordChunk,
213 pub weight: f32,
215 pub instruction: Option<String>,
217}
218
219#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
221pub enum PairLabel {
222 Positive,
224 Negative,
226}
227
228#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct SampleBatch {
231 pub pairs: Vec<SamplePair>,
233}
234
235impl SampleBatch {
236 pub fn is_empty(&self) -> bool {
238 self.pairs.is_empty()
239 }
240}
241
242#[derive(Clone, Debug, Serialize, Deserialize)]
244pub struct TripletBatch {
245 pub triplets: Vec<SampleTriplet>,
247}
248
249impl TripletBatch {
250 pub fn is_empty(&self) -> bool {
252 self.triplets.is_empty()
253 }
254}
255
256#[derive(Clone, Debug, Serialize, Deserialize)]
258pub struct TextSample {
259 pub recipe: String,
261 pub chunk: RecordChunk,
263 pub weight: f32,
265 pub instruction: Option<String>,
267}
268
269#[derive(Clone, Debug, Serialize, Deserialize)]
271pub struct TextBatch {
272 pub samples: Vec<TextSample>,
274}
275
276impl TextBatch {
277 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}