blazegraph-io-core 0.1.2

Core library for semantic document graph processing — parse PDFs into structured, queryable graphs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
use crate::types::PreprocessorOutput;
use crate::cache::GraphCacheKey;
use anyhow::{anyhow, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;

// =============================================================================
// Cache Point System (CR-11)
// =============================================================================
// The pipeline has four discrete cache points, numbered by position.
// "CachePoint" (not "CacheLayer") to avoid collision with L0/L1/L2 semantic layers.

/// A discrete cache point in the processing pipeline.
/// Ordered by pipeline position: C0 < C1 < C2 < C3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CachePoint {
    /// C0: Original PDF bytes
    C0,
    /// C1: Blazegraph XHTML from Tika/JNI extraction
    C1,
    /// C2: PreprocessorOutput (parsed elements + metadata)
    C2,
    /// C3: DocumentGraph (bgraph.json, config-dependent)
    C3,
}

impl CachePoint {
    /// All cache points in pipeline order.
    pub fn all() -> &'static [CachePoint] {
        &[CachePoint::C0, CachePoint::C1, CachePoint::C2, CachePoint::C3]
    }

    /// Cache points at and downstream of this point (for cascade operations).
    pub fn cascade(&self) -> Vec<CachePoint> {
        CachePoint::all().iter().copied().filter(|p| p >= self).collect()
    }

    /// Directory name for this cache point.
    pub fn dir_name(&self) -> &'static str {
        match self {
            CachePoint::C0 => "c0-pdf",
            CachePoint::C1 => "c1-xhtml",
            CachePoint::C2 => "c2-preprocessor",
            CachePoint::C3 => "c3-graph",
        }
    }

    /// Parse from CLI string (e.g., "c0", "c1", "c2", "c3", "all").
    pub fn from_str_with_all(s: &str) -> Result<Option<Self>> {
        match s.to_lowercase().as_str() {
            "c0" => Ok(Some(CachePoint::C0)),
            "c1" => Ok(Some(CachePoint::C1)),
            "c2" => Ok(Some(CachePoint::C2)),
            "c3" => Ok(Some(CachePoint::C3)),
            "all" => Ok(None), // None = all points
            _ => Err(anyhow!("Invalid cache point: '{}'. Use c0, c1, c2, c3, or all", s)),
        }
    }
}

impl std::fmt::Display for CachePoint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", match self {
            CachePoint::C0 => "C0 (PDF)",
            CachePoint::C1 => "C1 (XHTML)",
            CachePoint::C2 => "C2 (Preprocessor)",
            CachePoint::C3 => "C3 (Graph)",
        })
    }
}

/// Controls which cache points to bypass during processing.
/// Cascade: fresh-from C1 means skip C1, C2, C3 caches.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreshFrom {
    /// Use all caches normally
    None,
    /// Skip all caches, reprocess everything from PDF
    C0,
    /// Re-extract XHTML from Tika, reparse, rebuild graph
    C1,
    /// Reparse elements from cached XHTML, rebuild graph
    C2,
    /// Rebuild graph from cached preprocessor output
    C3,
}

impl FreshFrom {
    /// Should the cache be consulted for this point?
    pub fn should_use_cache(&self, point: CachePoint) -> bool {
        match self {
            FreshFrom::None => true,
            FreshFrom::C0 => false,
            FreshFrom::C1 => point < CachePoint::C1,
            FreshFrom::C2 => point < CachePoint::C2,
            FreshFrom::C3 => point < CachePoint::C3,
        }
    }

    /// Parse from CLI string.
    pub fn parse(s: &str) -> Result<Self> {
        match s.to_lowercase().as_str() {
            "c0" => Ok(FreshFrom::C0),
            "c1" => Ok(FreshFrom::C1),
            "c2" => Ok(FreshFrom::C2),
            "c3" => Ok(FreshFrom::C3),
            _ => Err(anyhow!("Invalid fresh-from value: '{}'. Use c0, c1, c2, or c3", s)),
        }
    }
}

/// Which cache points are enabled for writing.
#[derive(Debug, Clone)]
pub struct CacheDefaults {
    pub c0_pdf: bool,
    pub c1_xhtml: bool,
    pub c2_preprocessor: bool,
    pub c3_graph: bool,
}

impl Default for CacheDefaults {
    fn default() -> Self {
        Self {
            c0_pdf: false,
            c1_xhtml: true,
            c2_preprocessor: true,
            c3_graph: false,
        }
    }
}

impl CacheDefaults {
    /// Should we write to this cache point?
    pub fn should_write(&self, point: CachePoint) -> bool {
        match point {
            CachePoint::C0 => self.c0_pdf,
            CachePoint::C1 => self.c1_xhtml,
            CachePoint::C2 => self.c2_preprocessor,
            CachePoint::C3 => self.c3_graph,
        }
    }
}

/// Result of a cache clear operation.
pub struct CacheClearResult {
    pub deleted: Vec<(CachePoint, usize)>,
}

// =============================================================================
// Storage Trait
// =============================================================================

/// Storage abstraction for caching pipeline results at each cache point.
pub trait DocumentStorage {
    // C0: PDF storage
    fn get_pdf(&self, hash: &str) -> Result<Option<Vec<u8>>>;
    fn store_pdf(&self, hash: &str, data: &[u8]) -> Result<()>;

    // C1: Blazegraph XHTML (raw string, not JSON)
    fn get_xhtml(&self, pdf_hash: &str) -> Result<Option<String>>;
    fn store_xhtml(&self, pdf_hash: &str, xhtml: &str) -> Result<()>;

    // C2: PreprocessorOutput (parsed elements + metadata)
    fn get_preprocessor_output(&self, pdf_hash: &str) -> Result<Option<PreprocessorOutput>>;
    fn store_preprocessor_output(&self, pdf_hash: &str, output: &PreprocessorOutput) -> Result<()>;

    // C3: Graph output (config-dependent)
    fn get_graph_output(&self, cache_key: &GraphCacheKey) -> Result<Option<crate::cache::GraphCacheValue>>;
    fn store_graph_output(&self, cache_key: &GraphCacheKey, cache_value: &crate::cache::GraphCacheValue) -> Result<()>;

    // Cache management
    fn clear_cache(&self, from_point: Option<CachePoint>) -> Result<CacheClearResult>;
}

// =============================================================================
// File-based Storage
// =============================================================================

/// File-based storage implementation using local cache directory.
pub struct FileStorage {
    cache_dir: String,
}

impl FileStorage {
    pub fn new(cache_dir: &str) -> Result<Self> {
        fs::create_dir_all(cache_dir)?;
        for point in CachePoint::all() {
            fs::create_dir_all(format!("{}/{}", cache_dir, point.dir_name()))?;
        }
        fs::create_dir_all(format!("{cache_dir}/debug"))?;

        Ok(Self {
            cache_dir: cache_dir.to_string(),
        })
    }

    pub fn cache_dir(&self) -> &str {
        &self.cache_dir
    }

    fn pdf_path(&self, hash: &str) -> String {
        format!("{}/c0-pdf/{}.pdf", self.cache_dir, hash)
    }

    fn xhtml_path(&self, hash: &str) -> String {
        format!("{}/c1-xhtml/{}.xhtml", self.cache_dir, hash)
    }

    fn preprocessor_path(&self, hash: &str) -> String {
        format!("{}/c2-preprocessor/{}.json", self.cache_dir, hash)
    }

    fn graph_path(&self, cache_key: &GraphCacheKey) -> String {
        format!("{}/c3-graph/{}.json", self.cache_dir, cache_key.to_cache_hash())
    }

    /// Delete all files in a cache point directory, return count deleted.
    fn clear_dir(&self, point: CachePoint) -> Result<usize> {
        let dir = format!("{}/{}", self.cache_dir, point.dir_name());
        let path = Path::new(&dir);
        if !path.exists() {
            return Ok(0);
        }
        let mut count = 0;
        for entry in fs::read_dir(path)? {
            let entry = entry?;
            if entry.file_type()?.is_file() {
                fs::remove_file(entry.path())?;
                count += 1;
            }
        }
        Ok(count)
    }
}

impl DocumentStorage for FileStorage {
    // C0: PDF storage
    fn get_pdf(&self, hash: &str) -> Result<Option<Vec<u8>>> {
        let path = self.pdf_path(hash);
        if Path::new(&path).exists() {
            Ok(Some(fs::read(path)?))
        } else {
            Ok(None)
        }
    }

    fn store_pdf(&self, hash: &str, data: &[u8]) -> Result<()> {
        let path = self.pdf_path(hash);
        fs::write(path, data)?;
        Ok(())
    }

    // C1: Blazegraph XHTML (raw string file, .xhtml extension)
    fn get_xhtml(&self, pdf_hash: &str) -> Result<Option<String>> {
        let path = self.xhtml_path(pdf_hash);
        if Path::new(&path).exists() {
            Ok(Some(fs::read_to_string(path)?))
        } else {
            Ok(None)
        }
    }

    fn store_xhtml(&self, pdf_hash: &str, xhtml: &str) -> Result<()> {
        let path = self.xhtml_path(pdf_hash);
        fs::write(path, xhtml)?;
        Ok(())
    }

    // C2: PreprocessorOutput
    fn get_preprocessor_output(&self, pdf_hash: &str) -> Result<Option<PreprocessorOutput>> {
        let path = self.preprocessor_path(pdf_hash);
        if Path::new(&path).exists() {
            let json_str = fs::read_to_string(path)?;
            let output: PreprocessorOutput = serde_json::from_str(&json_str)
                .map_err(|e| anyhow!("Failed to deserialize cached PreprocessorOutput: {}", e))?;
            Ok(Some(output))
        } else {
            Ok(None)
        }
    }

    fn store_preprocessor_output(&self, pdf_hash: &str, output: &PreprocessorOutput) -> Result<()> {
        let path = self.preprocessor_path(pdf_hash);
        let json_str = serde_json::to_string_pretty(output)
            .map_err(|e| anyhow!("Failed to serialize PreprocessorOutput: {}", e))?;
        fs::write(path, json_str)?;
        Ok(())
    }

    // C3: Graph output
    fn get_graph_output(&self, cache_key: &GraphCacheKey) -> Result<Option<crate::cache::GraphCacheValue>> {
        let path = self.graph_path(cache_key);
        if Path::new(&path).exists() {
            let json_str = fs::read_to_string(path)?;
            let cache_value: crate::cache::GraphCacheValue = serde_json::from_str(&json_str)
                .map_err(|e| anyhow!("Failed to deserialize cached GraphCacheValue: {}", e))?;
            Ok(Some(cache_value))
        } else {
            Ok(None)
        }
    }

    fn store_graph_output(&self, cache_key: &GraphCacheKey, cache_value: &crate::cache::GraphCacheValue) -> Result<()> {
        let path = self.graph_path(cache_key);
        let json_str = serde_json::to_string_pretty(cache_value)
            .map_err(|e| anyhow!("Failed to serialize GraphCacheValue: {}", e))?;
        fs::write(path, json_str)?;
        Ok(())
    }

    // Cache management: cascading clear
    fn clear_cache(&self, from_point: Option<CachePoint>) -> Result<CacheClearResult> {
        let points_to_clear: Vec<CachePoint> = match from_point {
            Some(point) => point.cascade(),
            None => CachePoint::all().to_vec(), // "all"
        };

        let mut deleted = Vec::new();
        for point in points_to_clear {
            let count = self.clear_dir(point)?;
            if count > 0 {
                deleted.push((point, count));
            }
        }

        // Also clear debug/ when clearing all
        if from_point.is_none() {
            let debug_dir = format!("{}/debug", self.cache_dir);
            if Path::new(&debug_dir).exists() {
                let mut count = 0;
                for entry in fs::read_dir(&debug_dir)? {
                    let entry = entry?;
                    if entry.file_type()?.is_file() {
                        fs::remove_file(entry.path())?;
                        count += 1;
                    }
                }
                if count > 0 {
                    // Report as part of the output but not tied to a CachePoint
                    println!("   Deleted: {} files from debug/", count);
                }
            }
        }

        Ok(CacheClearResult { deleted })
    }
}

// =============================================================================
// Hash Functions
// =============================================================================

/// Calculate a fast hash for PDF content using start + end chunks
pub fn calculate_pdf_hash(pdf_bytes: &[u8]) -> String {
    let chunk_size = 1024; // 1KB from start and end
    let mut hasher = Sha256::new();

    // Hash file size first (for quick differentiation)
    hasher.update(pdf_bytes.len().to_le_bytes());

    // Hash first chunk
    let start_end = std::cmp::min(chunk_size, pdf_bytes.len());
    hasher.update(&pdf_bytes[0..start_end]);

    // Hash last chunk (if file is large enough)
    if pdf_bytes.len() > chunk_size {
        let end_start = pdf_bytes.len() - chunk_size;
        hasher.update(&pdf_bytes[end_start..]);
    }

    format!("{:x}", hasher.finalize())
}

/// Calculate hash for configuration data (for C3 cache key)
pub fn calculate_config_hash<T: serde::Serialize>(config: &T) -> Result<String> {
    let config_json = serde_json::to_string(config)
        .map_err(|e| anyhow!("Failed to serialize config for hashing: {}", e))?;

    let mut hasher = Sha256::new();
    hasher.update(config_json.as_bytes());
    Ok(format!("{:x}", hasher.finalize()))
}

/// Calculate hash for XHTML content
pub fn calculate_xhtml_hash(xhtml: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(xhtml.as_bytes());
    format!("{:x}", hasher.finalize())
}

// =============================================================================
// No-Op Storage (disables all caching)
// =============================================================================

pub struct NoOpStorage;

impl Default for NoOpStorage {
    fn default() -> Self {
        Self::new()
    }
}

impl NoOpStorage {
    pub fn new() -> Self {
        Self
    }
}

impl DocumentStorage for NoOpStorage {
    fn get_pdf(&self, _hash: &str) -> Result<Option<Vec<u8>>> { Ok(None) }
    fn store_pdf(&self, _hash: &str, _data: &[u8]) -> Result<()> { Ok(()) }
    fn get_xhtml(&self, _pdf_hash: &str) -> Result<Option<String>> { Ok(None) }
    fn store_xhtml(&self, _pdf_hash: &str, _xhtml: &str) -> Result<()> { Ok(()) }
    fn get_preprocessor_output(&self, _pdf_hash: &str) -> Result<Option<PreprocessorOutput>> { Ok(None) }
    fn store_preprocessor_output(&self, _pdf_hash: &str, _output: &PreprocessorOutput) -> Result<()> { Ok(()) }
    fn get_graph_output(&self, _cache_key: &GraphCacheKey) -> Result<Option<crate::cache::GraphCacheValue>> { Ok(None) }
    fn store_graph_output(&self, _cache_key: &GraphCacheKey, _cache_value: &crate::cache::GraphCacheValue) -> Result<()> { Ok(()) }
    fn clear_cache(&self, _from_point: Option<CachePoint>) -> Result<CacheClearResult> {
        Ok(CacheClearResult { deleted: vec![] })
    }
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_pdf_hash_consistency() {
        let pdf_data = b"test pdf content with some data";
        let hash1 = calculate_pdf_hash(pdf_data);
        let hash2 = calculate_pdf_hash(pdf_data);
        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_pdf_hash_uniqueness() {
        let pdf1 = b"test pdf content 1";
        let pdf2 = b"test pdf content 2";
        let hash1 = calculate_pdf_hash(pdf1);
        let hash2 = calculate_pdf_hash(pdf2);
        assert_ne!(hash1, hash2);
    }

    #[test]
    fn test_file_storage_roundtrip() {
        let temp_dir = std::env::temp_dir().join("blazegraph_test_cache_cr11");
        let _ = std::fs::remove_dir_all(&temp_dir); // clean slate
        let storage = FileStorage::new(temp_dir.to_str().unwrap()).unwrap();

        let test_data = b"test pdf data";
        let hash = "test_hash";

        // C0: Store and retrieve PDF
        storage.store_pdf(hash, test_data).unwrap();
        let retrieved = storage.get_pdf(hash).unwrap();
        assert_eq!(retrieved, Some(test_data.to_vec()));

        // C1: Store and retrieve XHTML
        let xhtml = "<html><body>test</body></html>";
        storage.store_xhtml(hash, xhtml).unwrap();
        let retrieved_xhtml = storage.get_xhtml(hash).unwrap();
        assert_eq!(retrieved_xhtml, Some(xhtml.to_string()));

        // Verify .xhtml file extension
        let xhtml_path = format!("{}/c1-xhtml/{}.xhtml", temp_dir.display(), hash);
        assert!(Path::new(&xhtml_path).exists());

        // Clean up
        std::fs::remove_dir_all(temp_dir).ok();
    }

    #[test]
    fn test_cache_point_ordering() {
        assert!(CachePoint::C0 < CachePoint::C1);
        assert!(CachePoint::C1 < CachePoint::C2);
        assert!(CachePoint::C2 < CachePoint::C3);
    }

    #[test]
    fn test_cache_point_cascade() {
        assert_eq!(CachePoint::C0.cascade(), vec![CachePoint::C0, CachePoint::C1, CachePoint::C2, CachePoint::C3]);
        assert_eq!(CachePoint::C1.cascade(), vec![CachePoint::C1, CachePoint::C2, CachePoint::C3]);
        assert_eq!(CachePoint::C2.cascade(), vec![CachePoint::C2, CachePoint::C3]);
        assert_eq!(CachePoint::C3.cascade(), vec![CachePoint::C3]);
    }

    #[test]
    fn test_fresh_from_cache_bypass() {
        // FreshFrom::None uses all caches
        assert!(FreshFrom::None.should_use_cache(CachePoint::C0));
        assert!(FreshFrom::None.should_use_cache(CachePoint::C3));

        // FreshFrom::C0 skips everything
        assert!(!FreshFrom::C0.should_use_cache(CachePoint::C0));
        assert!(!FreshFrom::C0.should_use_cache(CachePoint::C3));

        // FreshFrom::C2 uses C0 and C1, skips C2 and C3
        assert!(FreshFrom::C2.should_use_cache(CachePoint::C0));
        assert!(FreshFrom::C2.should_use_cache(CachePoint::C1));
        assert!(!FreshFrom::C2.should_use_cache(CachePoint::C2));
        assert!(!FreshFrom::C2.should_use_cache(CachePoint::C3));
    }

    #[test]
    fn test_clear_cache_cascade() {
        let temp_dir = std::env::temp_dir().join("blazegraph_test_clear_cr11");
        let _ = std::fs::remove_dir_all(&temp_dir);
        let storage = FileStorage::new(temp_dir.to_str().unwrap()).unwrap();

        // Populate C1 and C2
        storage.store_xhtml("hash1", "<html>test</html>").unwrap();
        storage.store_xhtml("hash2", "<html>test2</html>").unwrap();

        // Clear from C1 should cascade to C2 and C3
        let result = storage.clear_cache(Some(CachePoint::C1)).unwrap();
        assert!(result.deleted.iter().any(|(p, c)| *p == CachePoint::C1 && *c == 2));

        // Verify files are gone
        assert!(storage.get_xhtml("hash1").unwrap().is_none());
        assert!(storage.get_xhtml("hash2").unwrap().is_none());

        std::fs::remove_dir_all(temp_dir).ok();
    }

    #[test]
    fn test_cache_defaults() {
        let defaults = CacheDefaults::default();
        assert!(!defaults.should_write(CachePoint::C0));
        assert!(defaults.should_write(CachePoint::C1));
        assert!(defaults.should_write(CachePoint::C2));
        assert!(!defaults.should_write(CachePoint::C3));
    }
}