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#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
12pub struct QualityScore {
13 pub trust: f32,
15}
16
17impl Default for QualityScore {
18 fn default() -> Self {
19 Self {
20 trust: 0.5,
22 }
23 }
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct DataRecord {
29 pub id: RecordId,
31 pub source: SourceId,
33 pub created_at: DateTime<Utc>,
35 pub updated_at: DateTime<Utc>,
37 pub quality: QualityScore,
39 pub taxonomy: Vec<TaxonomyValue>,
41 pub sections: Vec<RecordSection>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub meta_prefix: Option<KvpPrefixSampler>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub label: Option<PairLabel>,
52}
53
54impl DataRecord {
55 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct RecordSection {
123 pub role: SectionRole,
125 pub heading: Option<String>,
127 pub text: String,
129 pub sentences: Vec<Sentence>,
131 #[serde(default)]
133 pub token_count: usize,
134 #[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
160pub enum SectionRole {
161 Anchor,
163 Context,
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
169pub struct RecordChunk {
170 pub record_id: RecordId,
172 pub source: SourceId,
179 pub section_idx: usize,
181 pub view: ChunkView,
183 pub text: String,
185 pub tokens_estimate: usize,
187 pub quality: QualityScore,
189 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
197 pub kvp_meta: HashMap<String, Vec<String>>,
198 #[serde(skip)]
204 pub embedding: Option<Arc<[f32]>>,
205}
206
207#[derive(Clone, Debug, Serialize, Deserialize)]
209pub enum ChunkView {
210 Window {
212 index: usize,
214 overlap: usize,
216 span: usize,
218 },
219 SummaryFallback {
221 strategy: String,
223 weight: f32,
225 },
226}
227
228#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct SamplePair {
231 pub recipe: String,
233 pub anchor: RecordChunk,
235 pub positive: RecordChunk,
237 pub weight: f32,
239 pub instruction: Option<String>,
241 pub label: PairLabel,
243 pub reason: Option<String>,
245}
246
247#[derive(Clone, Debug, Serialize, Deserialize)]
249pub struct SampleTriplet {
250 pub recipe: String,
252 pub anchor: RecordChunk,
254 pub positive: RecordChunk,
256 pub negative: RecordChunk,
258 pub weight: f32,
260 pub instruction: Option<String>,
262}
263
264#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
266pub enum PairLabel {
267 Positive,
269 Negative,
271}
272
273#[derive(Clone, Debug, Serialize, Deserialize)]
275pub struct SampleBatch {
276 pub pairs: Vec<SamplePair>,
278}
279
280impl SampleBatch {
281 pub fn is_empty(&self) -> bool {
283 self.pairs.is_empty()
284 }
285}
286
287#[derive(Clone, Debug, Serialize, Deserialize)]
289pub struct TripletBatch {
290 pub triplets: Vec<SampleTriplet>,
292}
293
294impl TripletBatch {
295 pub fn is_empty(&self) -> bool {
297 self.triplets.is_empty()
298 }
299}
300
301#[derive(Clone, Debug, Serialize, Deserialize)]
303pub struct TextSample {
304 pub recipe: String,
306 pub chunk: RecordChunk,
308 pub weight: f32,
310 pub instruction: Option<String>,
312}
313
314#[derive(Clone, Debug, Serialize, Deserialize)]
316pub struct TextBatch {
317 pub samples: Vec<TextSample>,
319}
320
321impl TextBatch {
322 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 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}