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 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#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct RecordSection {
121 pub role: SectionRole,
123 pub heading: Option<String>,
125 pub text: String,
127 pub sentences: Vec<Sentence>,
129 #[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
148pub enum SectionRole {
149 Anchor,
151 Context,
153}
154
155#[derive(Clone, Debug, Serialize, Deserialize)]
157pub struct RecordChunk {
158 pub record_id: RecordId,
160 pub section_idx: usize,
162 pub view: ChunkView,
164 pub text: String,
166 pub tokens_estimate: usize,
168 pub quality: QualityScore,
170 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
178 pub kvp_meta: HashMap<String, Vec<String>>,
179}
180
181#[derive(Clone, Debug, Serialize, Deserialize)]
183pub enum ChunkView {
184 Window {
186 index: usize,
188 overlap: usize,
190 span: usize,
192 },
193 SummaryFallback {
195 strategy: String,
197 weight: f32,
199 },
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct SamplePair {
205 pub recipe: String,
207 pub anchor: RecordChunk,
209 pub positive: RecordChunk,
211 pub weight: f32,
213 pub instruction: Option<String>,
215 pub label: PairLabel,
217 pub reason: Option<String>,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize)]
223pub struct SampleTriplet {
224 pub recipe: String,
226 pub anchor: RecordChunk,
228 pub positive: RecordChunk,
230 pub negative: RecordChunk,
232 pub weight: f32,
234 pub instruction: Option<String>,
236}
237
238#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
240pub enum PairLabel {
241 Positive,
243 Negative,
245}
246
247#[derive(Clone, Debug, Serialize, Deserialize)]
249pub struct SampleBatch {
250 pub pairs: Vec<SamplePair>,
252}
253
254impl SampleBatch {
255 pub fn is_empty(&self) -> bool {
257 self.pairs.is_empty()
258 }
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
263pub struct TripletBatch {
264 pub triplets: Vec<SampleTriplet>,
266}
267
268impl TripletBatch {
269 pub fn is_empty(&self) -> bool {
271 self.triplets.is_empty()
272 }
273}
274
275#[derive(Clone, Debug, Serialize, Deserialize)]
277pub struct TextSample {
278 pub recipe: String,
280 pub chunk: RecordChunk,
282 pub weight: f32,
284 pub instruction: Option<String>,
286}
287
288#[derive(Clone, Debug, Serialize, Deserialize)]
290pub struct TextBatch {
291 pub samples: Vec<TextSample>,
293}
294
295impl TextBatch {
296 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}