Skip to main content

cognee_models/
document.rs

1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use uuid::Uuid;
4
5use crate::Data;
6use crate::DataPoint;
7use crate::has_datapoint::HasDataPoint;
8
9/// A classified document derived from a Data item.
10///
11/// Mirrors the Python `Document` class hierarchy. In Python, each document type
12/// is a separate class (TextDocument, PdfDocument, etc.). In Rust we use a single
13/// struct with a `document_type` field and the `base.data_type` discriminator
14/// set to the class name (e.g. "TextDocument", "PdfDocument").
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Document {
17    /// DataPoint base — carries id, timestamps, metadata, data_type discriminator.
18    #[serde(flatten)]
19    pub base: DataPoint,
20    /// Document type category: "text", "pdf", "csv", "html", "image", "audio", "unstructured", "dlt_row".
21    pub document_type: String,
22    pub name: String,
23    pub raw_data_location: String,
24    pub mime_type: String,
25    pub extension: String,
26    /// Reference back to the source Data record.
27    pub data_id: Uuid,
28    /// Pretty-printed external metadata JSON, if any.
29    pub external_metadata: Option<String>,
30}
31
32/// Map a file extension to a document type string, returning `None` for
33/// unrecognised extensions.
34///
35/// Public wrapper over the internal [`extension_to_doc_type`] mapping so the
36/// ingestion pipeline can pick the right loader at ADD time using the same
37/// extension → document-type table that [`classify_documents`] uses at cognify
38/// time.
39pub fn doc_type_for_extension(ext: &str) -> Option<&'static str> {
40    extension_to_doc_type(ext)
41}
42
43/// Map a file extension to a document type string.
44///
45/// Matches the 39-entry `EXTENSION_TO_DOCUMENT_CLASS` mapping in the Python SDK
46/// (`cognee/tasks/documents/classify_documents.py`).
47fn extension_to_doc_type(ext: &str) -> Option<&'static str> {
48    match ext.to_lowercase().as_str() {
49        "pdf" => Some("pdf"),
50        "txt" => Some("text"),
51        "csv" => Some("csv"),
52        "docx" | "doc" | "odt" | "xls" | "xlsx" | "ppt" | "pptx" | "odp" | "ods" => {
53            Some("unstructured")
54        }
55        "png" | "dwg" | "xcf" | "jpg" | "jpx" | "apng" | "gif" | "webp" | "cr2" | "tif" | "bmp"
56        | "jxr" | "psd" | "ico" | "heic" | "avif" => Some("image"),
57        "aac" | "mid" | "mp3" | "m4a" | "ogg" | "flac" | "wav" | "amr" | "aiff" => Some("audio"),
58        // HTML — handled by the BeautifulSoup-equivalent loader. Note: Python's
59        // `EXTENSION_TO_DOCUMENT_CLASS` has no `html` entry because its
60        // BeautifulSoupLoader runs at add-time and stores extracted text as a
61        // TextDocument. Rust runs loaders at cognify-time keyed on
62        // `document_type`, so we classify html/htm to a dedicated "html"
63        // document type while keeping the `TextDocument` class discriminator
64        // (see `doc_type_to_class_name`) for cross-SDK DB parity.
65        "html" | "htm" => Some("html"),
66        _ => None,
67    }
68}
69
70/// Return the `data_type` discriminator (Python class name) for a document type.
71fn doc_type_to_class_name(doc_type: &str) -> &'static str {
72    match doc_type {
73        "text" => "TextDocument",
74        "pdf" => "PdfDocument",
75        "csv" => "CsvDocument",
76        "image" => "ImageDocument",
77        "audio" => "AudioDocument",
78        // HTML content becomes text; Python stores it as a TextDocument, so we
79        // match that node `data_type` for cross-SDK graph parity.
80        "html" => "TextDocument",
81        "unstructured" => "UnstructuredDocument",
82        "dlt_row" => "DltRowDocument",
83        _ => "Document",
84    }
85}
86
87/// Check whether the `external_metadata` JSON indicates a DLT source.
88///
89/// Mirrors Python `cognee/tasks/ingestion/dlt_utils.py:is_dlt_sourced`.
90fn is_dlt_sourced(external_metadata: &Option<String>) -> bool {
91    external_metadata
92        .as_ref()
93        .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
94        .map(|v| metadata_value_is_dlt_sourced(&v))
95        .unwrap_or(false)
96}
97
98fn metadata_value_is_dlt_sourced(value: &serde_json::Value) -> bool {
99    value
100        .get("source")
101        .and_then(|source| source.as_str())
102        .map(|source| source == "dlt")
103        .unwrap_or(false)
104        || value
105            .get("data_item_external_metadata")
106            .map(metadata_value_is_dlt_sourced)
107            .unwrap_or(false)
108}
109
110/// Classify Data items into Documents based on file extension.
111///
112/// Mirrors the Python `classify_documents` function. DLT-sourced items are
113/// classified as `DltRowDocument`; all others use the extension-to-document-type
114/// mapping. Items with unrecognised extensions are silently skipped.
115pub fn classify_documents(data_items: &[Data]) -> Vec<Document> {
116    data_items
117        .iter()
118        .filter_map(|data| {
119            // DLT detection takes priority
120            let doc_type = if is_dlt_sourced(&data.external_metadata) {
121                "dlt_row"
122            } else {
123                extension_to_doc_type(&data.extension)?
124            };
125
126            let class_name = doc_type_to_class_name(doc_type);
127            let mut base = DataPoint::new(class_name, None);
128            base.id = data.id; // use Data's deterministic ID
129            // Port of classify_documents.py:144-146 — coalesce None to 0.5.
130            base.importance_weight = Some(data.importance_weight.unwrap_or(0.5));
131            base.set_metadata("index_fields", json!(["name"]));
132
133            // Format external_metadata as indented JSON (Python does json.dumps(..., indent=4))
134            let formatted_metadata = data.external_metadata.as_ref().and_then(|m| {
135                let v: serde_json::Value = serde_json::from_str(m).ok()?;
136                serde_json::to_string_pretty(&v).ok()
137            });
138
139            let mut doc = Document {
140                base,
141                document_type: doc_type.to_string(),
142                name: data.name.clone(),
143                raw_data_location: data.raw_data_location.clone(),
144                mime_type: data.mime_type.clone(),
145                extension: data.extension.clone(),
146                data_id: data.id,
147                external_metadata: formatted_metadata.or(data.external_metadata.clone()),
148            };
149
150            // update_node_set: parse external_metadata for node_set array
151            // Mirrors Python cognee/tasks/documents/classify_documents.py:update_node_set()
152            if let Some(ref meta_str) = doc.external_metadata
153                && let Ok(meta_val) = serde_json::from_str::<serde_json::Value>(meta_str)
154                && let Some(node_set_array) = meta_val.get("node_set").and_then(|v| v.as_array())
155            {
156                // Build NodeSet-like JSON values with deterministic IDs
157                // Python: NodeSet(id=generate_node_id(f"NodeSet:{name}"), name=name)
158                let node_set_values: Vec<serde_json::Value> = node_set_array
159                    .iter()
160                    .filter_map(|v| {
161                        let name = v.as_str()?;
162                        let key = format!("NodeSet:{name}")
163                            .to_lowercase()
164                            .replace(' ', "_")
165                            .replace('\'', "");
166                        let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes());
167                        Some(json!({
168                            "id": id.to_string(),
169                            "name": name,
170                            "type": "NodeSet"
171                        }))
172                    })
173                    .collect();
174
175                if !node_set_values.is_empty() {
176                    // source_node_set = comma-separated names (Python: ", ".join(node_set))
177                    let names: Vec<&str> =
178                        node_set_array.iter().filter_map(|v| v.as_str()).collect();
179                    doc.base.source_node_set = Some(names.join(", "));
180                    doc.base.belongs_to_set = Some(node_set_values);
181                }
182            }
183
184            Some(doc)
185        })
186        .collect()
187}
188
189impl HasDataPoint for Document {
190    fn data_point(&self) -> &DataPoint {
191        &self.base
192    }
193    fn data_point_mut(&mut self) -> &mut DataPoint {
194        &mut self.base
195    }
196    // for_each_child_mut: default no-op — Document has no nested
197    // DataPoint-bearing fields (links to its source `Data` by `data_id: Uuid`).
198}
199
200#[cfg(test)]
201#[allow(
202    clippy::unwrap_used,
203    clippy::expect_used,
204    reason = "test code — panics are acceptable failures"
205)]
206mod tests {
207    use super::*;
208
209    fn make_data(mime_type: &str, extension: &str) -> Data {
210        Data::builder(
211            Uuid::new_v4(),
212            format!("test.{extension}"),
213            "/storage/test",
214            "text://test",
215            extension,
216            mime_type,
217            "hash123",
218            Uuid::new_v4(),
219        )
220        .build()
221    }
222
223    fn make_data_with_metadata(mime_type: &str, extension: &str, metadata: &str) -> Data {
224        Data::builder(
225            Uuid::new_v4(),
226            format!("test.{extension}"),
227            "/storage/test",
228            "text://test",
229            extension,
230            mime_type,
231            "hash123",
232            Uuid::new_v4(),
233        )
234        .external_metadata(metadata)
235        .build()
236    }
237
238    fn make_data_with_importance(
239        mime_type: &str,
240        extension: &str,
241        importance_weight: Option<f64>,
242    ) -> Data {
243        let mut builder = Data::builder(
244            Uuid::new_v4(),
245            format!("test.{extension}"),
246            "/storage/test",
247            "text://test",
248            extension,
249            mime_type,
250            "hash123",
251            Uuid::new_v4(),
252        );
253        if let Some(w) = importance_weight {
254            builder = builder.importance_weight(w);
255        }
256        builder.build()
257    }
258
259    // ----- Extension-based classification tests -----
260
261    #[test]
262    fn classify_documents_propagates_importance_weight() {
263        let data = vec![make_data_with_importance("text/plain", "txt", Some(0.9))];
264        let docs = classify_documents(&data);
265        assert_eq!(docs.len(), 1);
266        assert_eq!(docs[0].base.importance_weight, Some(0.9));
267    }
268
269    #[test]
270    fn classify_documents_coalesces_none_importance_weight_to_default() {
271        let data = vec![make_data_with_importance("text/plain", "txt", None)];
272        let docs = classify_documents(&data);
273        assert_eq!(docs.len(), 1);
274        assert_eq!(docs[0].base.importance_weight, Some(0.5));
275    }
276
277    #[test]
278    fn classifies_text_plain() {
279        let data = vec![make_data("text/plain", "txt")];
280        let docs = classify_documents(&data);
281        assert_eq!(docs.len(), 1);
282        assert_eq!(docs[0].document_type, "text");
283        assert_eq!(docs[0].base.data_type, "TextDocument");
284        assert_eq!(docs[0].mime_type, "text/plain");
285        assert_eq!(docs[0].data_id, data[0].id);
286        assert_eq!(docs[0].base.id, data[0].id);
287        assert_eq!(docs[0].base.data_type, "TextDocument");
288        assert_eq!(
289            docs[0].base.get_metadata("index_fields"),
290            Some(&serde_json::json!(["name"]))
291        );
292    }
293
294    #[test]
295    fn classifies_extracted_html_url_text_as_text_document() {
296        let data = vec![
297            Data::builder(
298                Uuid::new_v4(),
299                "text_hash",
300                "file:///storage/text_hash.txt",
301                "file:///storage/source.html",
302                "txt",
303                "text/plain",
304                "hash123",
305                Uuid::new_v4(),
306            )
307            .original_extension("html")
308            .original_mime_type("text/html")
309            .loader_engine("beautiful_soup_loader")
310            .external_metadata(
311                r#"{"source":"url","url":"https://example.test","content_type":"text/html"}"#,
312            )
313            .build(),
314        ];
315
316        let docs = classify_documents(&data);
317
318        assert_eq!(docs.len(), 1);
319        assert_eq!(docs[0].document_type, "text");
320        assert_eq!(docs[0].base.data_type, "TextDocument");
321        assert_eq!(docs[0].extension, "txt");
322        assert_eq!(docs[0].mime_type, "text/plain");
323    }
324
325    #[test]
326    fn classifies_pdf() {
327        let data = vec![make_data("application/pdf", "pdf")];
328        let docs = classify_documents(&data);
329        assert_eq!(docs.len(), 1);
330        assert_eq!(docs[0].document_type, "pdf");
331        assert_eq!(docs[0].base.data_type, "PdfDocument");
332    }
333
334    #[test]
335    fn classifies_csv() {
336        let data = vec![make_data("text/csv", "csv")];
337        let docs = classify_documents(&data);
338        assert_eq!(docs.len(), 1);
339        assert_eq!(docs[0].document_type, "csv");
340        assert_eq!(docs[0].base.data_type, "CsvDocument");
341    }
342
343    #[test]
344    fn classifies_image_extensions() {
345        for ext in &[
346            "png", "dwg", "xcf", "jpg", "jpx", "apng", "gif", "webp", "cr2", "tif", "bmp", "jxr",
347            "psd", "ico", "heic", "avif",
348        ] {
349            let data = vec![make_data(&format!("image/{ext}"), ext)];
350            let docs = classify_documents(&data);
351            assert_eq!(docs.len(), 1, "failed for extension: {ext}");
352            assert_eq!(
353                docs[0].document_type, "image",
354                "failed for extension: {ext}"
355            );
356            assert_eq!(
357                docs[0].base.data_type, "ImageDocument",
358                "failed for extension: {ext}"
359            );
360        }
361    }
362
363    #[test]
364    fn classifies_audio_extensions() {
365        for ext in &[
366            "aac", "mid", "mp3", "m4a", "ogg", "flac", "wav", "amr", "aiff",
367        ] {
368            let data = vec![make_data(&format!("audio/{ext}"), ext)];
369            let docs = classify_documents(&data);
370            assert_eq!(docs.len(), 1, "failed for extension: {ext}");
371            assert_eq!(
372                docs[0].document_type, "audio",
373                "failed for extension: {ext}"
374            );
375            assert_eq!(
376                docs[0].base.data_type, "AudioDocument",
377                "failed for extension: {ext}"
378            );
379        }
380    }
381
382    #[test]
383    fn classifies_unstructured_extensions() {
384        for ext in &[
385            "docx", "doc", "odt", "xls", "xlsx", "ppt", "pptx", "odp", "ods",
386        ] {
387            let data = vec![make_data("application/octet-stream", ext)];
388            let docs = classify_documents(&data);
389            assert_eq!(docs.len(), 1, "failed for extension: {ext}");
390            assert_eq!(
391                docs[0].document_type, "unstructured",
392                "failed for extension: {ext}"
393            );
394            assert_eq!(
395                docs[0].base.data_type, "UnstructuredDocument",
396                "failed for extension: {ext}"
397            );
398        }
399    }
400
401    #[test]
402    fn classifies_html_extensions() {
403        for ext in &["html", "htm"] {
404            let data = vec![make_data("text/html", ext)];
405            let docs = classify_documents(&data);
406            assert_eq!(docs.len(), 1, "failed for extension: {ext}");
407            assert_eq!(docs[0].document_type, "html", "failed for extension: {ext}");
408            // Cross-SDK parity: Python's BeautifulSoupLoader produces a
409            // TextDocument, so HTML documents carry the TextDocument data_type.
410            assert_eq!(
411                docs[0].base.data_type, "TextDocument",
412                "failed for extension: {ext}"
413            );
414        }
415    }
416
417    // ----- Unknown extensions are skipped -----
418
419    #[test]
420    fn skips_unknown_extensions() {
421        let data = vec![make_data("application/octet-stream", "xyz")];
422        let docs = classify_documents(&data);
423        assert!(docs.is_empty());
424    }
425
426    #[test]
427    fn source_code_extensions_are_not_classified() {
428        for ext in &["py", "rs", "js", "ts", "c", "cpp", "go", "java", "rb", "sh"] {
429            let data = vec![make_data("text/plain", ext)];
430            let docs = classify_documents(&data);
431            assert!(docs.is_empty(), "extension .{ext} should not be classified");
432        }
433    }
434
435    // ----- Mixed input: only known extensions pass through -----
436
437    #[test]
438    fn mixed_input_filters_correctly() {
439        let data = vec![
440            make_data("text/plain", "txt"),
441            make_data("application/octet-stream", "xyz"),
442            make_data("application/pdf", "pdf"),
443            make_data("image/png", "png"),
444            make_data("audio/mp3", "mp3"),
445        ];
446        let docs = classify_documents(&data);
447        assert_eq!(docs.len(), 4);
448        assert_eq!(docs[0].document_type, "text");
449        assert_eq!(docs[1].document_type, "pdf");
450        assert_eq!(docs[2].document_type, "image");
451        assert_eq!(docs[3].document_type, "audio");
452    }
453
454    // ----- DLT detection -----
455
456    #[test]
457    fn classifies_dlt_sourced_data() {
458        let data = vec![make_data_with_metadata(
459            "text/plain",
460            "txt",
461            r#"{"source": "dlt"}"#,
462        )];
463        let docs = classify_documents(&data);
464        assert_eq!(docs.len(), 1);
465        assert_eq!(docs[0].document_type, "dlt_row");
466        assert_eq!(docs[0].base.data_type, "DltRowDocument");
467    }
468
469    #[test]
470    fn dlt_detection_with_unknown_extension() {
471        // DLT sourced items should be classified even with unknown extensions
472        let data = vec![make_data_with_metadata(
473            "application/octet-stream",
474            "xyz",
475            r#"{"source": "dlt"}"#,
476        )];
477        let docs = classify_documents(&data);
478        assert_eq!(docs.len(), 1);
479        assert_eq!(docs[0].document_type, "dlt_row");
480    }
481
482    #[test]
483    fn dlt_detection_survives_url_metadata_merge_conflict() {
484        let data = vec![make_data_with_metadata(
485            "text/plain",
486            "txt",
487            r#"{"source":"url","url":"https://example.test","data_item_external_metadata":{"source":"dlt","table":"events"}}"#,
488        )];
489        let docs = classify_documents(&data);
490        assert_eq!(docs.len(), 1);
491        assert_eq!(docs[0].document_type, "dlt_row");
492        assert_eq!(docs[0].base.data_type, "DltRowDocument");
493    }
494
495    #[test]
496    fn non_dlt_metadata_does_not_affect_classification() {
497        let data = vec![make_data_with_metadata(
498            "text/plain",
499            "txt",
500            r#"{"source": "other"}"#,
501        )];
502        let docs = classify_documents(&data);
503        assert_eq!(docs.len(), 1);
504        assert_eq!(docs[0].document_type, "text");
505    }
506
507    // ----- External metadata formatting -----
508
509    #[test]
510    fn formats_external_metadata_as_pretty_json() {
511        let data = vec![make_data_with_metadata(
512            "text/plain",
513            "txt",
514            r#"{"key":"value","nested":{"a":1}}"#,
515        )];
516        let docs = classify_documents(&data);
517        assert_eq!(docs.len(), 1);
518        let meta = docs[0].external_metadata.as_ref().unwrap();
519        // Pretty-printed JSON should contain newlines and indentation
520        assert!(meta.contains('\n'));
521        assert!(meta.contains("  "));
522    }
523
524    #[test]
525    fn invalid_json_metadata_passed_through_as_is() {
526        let data = vec![make_data_with_metadata("text/plain", "txt", "not-json")];
527        let docs = classify_documents(&data);
528        assert_eq!(docs.len(), 1);
529        // Invalid JSON can't be pretty-printed, so original is kept
530        assert_eq!(docs[0].external_metadata.as_ref().unwrap(), "not-json");
531    }
532
533    // ----- DataPoint base -----
534
535    #[test]
536    fn document_has_index_fields_metadata() {
537        let data = vec![make_data("text/plain", "txt")];
538        let docs = classify_documents(&data);
539        assert_eq!(
540            docs[0].base.get_metadata("index_fields"),
541            Some(&json!(["name"]))
542        );
543    }
544
545    #[test]
546    fn document_id_matches_data_id() {
547        let data = vec![make_data("text/plain", "txt")];
548        let docs = classify_documents(&data);
549        assert_eq!(docs[0].base.id, data[0].id);
550        assert_eq!(docs[0].data_id, data[0].id);
551    }
552
553    // ----- Empty input -----
554
555    #[test]
556    fn empty_input() {
557        let docs = classify_documents(&[]);
558        assert!(docs.is_empty());
559    }
560
561    // ----- Case insensitivity -----
562
563    #[test]
564    fn extension_matching_is_case_insensitive() {
565        assert_eq!(extension_to_doc_type("PDF"), Some("pdf"));
566        assert_eq!(extension_to_doc_type("Txt"), Some("text"));
567        assert_eq!(extension_to_doc_type("PNG"), Some("image"));
568        assert_eq!(extension_to_doc_type("MP3"), Some("audio"));
569    }
570
571    // ----- NodeSet handling (update_node_set) -----
572
573    #[test]
574    fn node_set_populates_belongs_to_set_and_source_node_set() {
575        let data = vec![make_data_with_metadata(
576            "text/plain",
577            "txt",
578            r#"{"node_set": ["setA", "setB"]}"#,
579        )];
580        let docs = classify_documents(&data);
581        assert_eq!(docs.len(), 1);
582
583        // belongs_to_set should have two NodeSet entries
584        let bts = docs[0].base.belongs_to_set.as_ref().unwrap();
585        assert_eq!(bts.len(), 2);
586
587        // Each entry should have id, name, type
588        assert_eq!(bts[0]["name"], "setA");
589        assert_eq!(bts[0]["type"], "NodeSet");
590        assert_eq!(bts[1]["name"], "setB");
591        assert_eq!(bts[1]["type"], "NodeSet");
592
593        // IDs should be deterministic UUID5
594        let key_a = "nodeset:seta"; // lowercased, spaces→underscores
595        let expected_id_a =
596            uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key_a.as_bytes()).to_string();
597        assert_eq!(bts[0]["id"], expected_id_a);
598
599        // source_node_set should be comma-separated names
600        assert_eq!(docs[0].base.source_node_set.as_ref().unwrap(), "setA, setB");
601    }
602
603    #[test]
604    fn node_set_single_entry() {
605        let data = vec![make_data_with_metadata(
606            "text/plain",
607            "txt",
608            r#"{"node_set": ["only_one"]}"#,
609        )];
610        let docs = classify_documents(&data);
611        assert_eq!(docs.len(), 1);
612
613        let bts = docs[0].base.belongs_to_set.as_ref().unwrap();
614        assert_eq!(bts.len(), 1);
615        assert_eq!(bts[0]["name"], "only_one");
616
617        assert_eq!(docs[0].base.source_node_set.as_ref().unwrap(), "only_one");
618    }
619
620    #[test]
621    fn no_node_set_key_leaves_belongs_to_set_unset() {
622        let data = vec![make_data_with_metadata(
623            "text/plain",
624            "txt",
625            r#"{"other_key": "value"}"#,
626        )];
627        let docs = classify_documents(&data);
628        assert_eq!(docs.len(), 1);
629        assert!(docs[0].base.belongs_to_set.is_none());
630        assert!(docs[0].base.source_node_set.is_none());
631    }
632
633    #[test]
634    fn node_set_not_array_leaves_belongs_to_set_unset() {
635        let data = vec![make_data_with_metadata(
636            "text/plain",
637            "txt",
638            r#"{"node_set": "not_an_array"}"#,
639        )];
640        let docs = classify_documents(&data);
641        assert_eq!(docs.len(), 1);
642        assert!(docs[0].base.belongs_to_set.is_none());
643        assert!(docs[0].base.source_node_set.is_none());
644    }
645
646    #[test]
647    fn node_set_empty_array_leaves_belongs_to_set_unset() {
648        let data = vec![make_data_with_metadata(
649            "text/plain",
650            "txt",
651            r#"{"node_set": []}"#,
652        )];
653        let docs = classify_documents(&data);
654        assert_eq!(docs.len(), 1);
655        // Empty array produces no NodeSet values, so stays unset
656        assert!(docs[0].base.belongs_to_set.is_none());
657        assert!(docs[0].base.source_node_set.is_none());
658    }
659
660    #[test]
661    fn node_set_with_no_metadata_leaves_belongs_to_set_unset() {
662        let data = vec![make_data("text/plain", "txt")];
663        let docs = classify_documents(&data);
664        assert_eq!(docs.len(), 1);
665        assert!(docs[0].base.belongs_to_set.is_none());
666        assert!(docs[0].base.source_node_set.is_none());
667    }
668
669    #[test]
670    fn document_implements_has_datapoint() {
671        let data = vec![make_data("text/plain", "txt")];
672        let docs = classify_documents(&data);
673        assert_eq!(docs.len(), 1);
674        let dp_id = docs[0].base.id;
675        assert_eq!(docs[0].data_point().id, dp_id);
676        let mut doc = docs[0].clone();
677        assert_eq!(doc.data_point_mut().id, dp_id);
678    }
679}