bra0-kg 0.2.0

bra0 Knowledge Graph core — RDF transformations (sophia) + KgStore trait (NextGraph-First)
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
//! Symbolic Cascade — NS-5
//!
//! Orchestrates the 3-tier neurosymbolic pipeline:
//!   Tier 1: OWL 2 RL materialization (symbolic — owl.rs)
//!   Tier 2: NER extraction (neural — ner.rs)
//!   Tier 3: LLM fallback (not yet implemented)
//!
//! Design principle (P1 Least Power): symbolic reasoning runs first and handles
//! what it can. Neural extraction runs only for what symbolic rules cannot cover.
//! LLM is a last resort (< 3% of invocations expected).
//!
//! Pipeline: materialize → extract → merge → validate
//!
//! Reference: ADR-027 (Symbolic Cascade), sprint-plan-v08

use crate::owl;
use crate::store::KgStore;

/// Origin tag — tracks whether a triple came from symbolic or neural inference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TripleOrigin {
    /// Produced by OWL 2 RL materialization (tier 1)
    Symbolic,
    /// Produced by NER zero-shot extraction (tier 2)
    Neural,
}

/// A candidate triple proposed by the cascade, with provenance.
#[derive(Debug, Clone)]
pub struct CascadeTriple {
    pub subject: String,
    pub predicate: String,
    pub object: String,
    pub origin: TripleOrigin,
    /// Confidence score: 1.0 for symbolic (deterministic), 0..1 for neural
    pub confidence: f32,
}

/// SHACL validation report from rudof post-merge quality gate.
#[derive(Debug, Clone)]
pub struct ShaclReport {
    /// Whether the data conforms to all shapes
    pub conforms: bool,
    /// Number of sh:Violation results
    pub violations: usize,
    /// Number of sh:Warning results
    pub warnings: usize,
    /// Raw output from rudof (details format)
    pub details: String,
}

/// Summary of a cascade run.
#[derive(Debug)]
pub struct CascadeResult {
    /// Triples produced by tier 1 (OWL 2 RL materialization)
    pub symbolic_count: usize,
    /// Triples produced by tier 2 (NER extraction)
    pub neural_count: usize,
    /// Triples removed during deduplication (neural overlapped with symbolic)
    pub dedup_removed: usize,
    /// SHACL validation report (None if no shapes provided)
    pub shacl_report: Option<ShaclReport>,
    /// Final merged triples (symbolic + neural, after dedup + validation)
    pub triples: Vec<CascadeTriple>,
}

/// Configuration for a cascade run.
#[derive(Debug, Clone)]
pub struct CascadeConfig {
    /// NER similarity threshold (candidates below this are discarded).
    /// Default: 0.85
    pub ner_threshold: f32,
    /// Maximum OWL fixpoint iterations. Default: 50
    pub max_owl_iterations: usize,
    /// SHACL shapes (Turtle) for post-merge validation. If empty, skip validation.
    pub shacl_shapes: Option<String>,
}

impl Default for CascadeConfig {
    fn default() -> Self {
        Self {
            ner_threshold: 0.85,
            max_owl_iterations: 50,
            shacl_shapes: None,
        }
    }
}

// ─── Tier 1: Symbolic ─────────────────────────────────────────────────────

/// Run tier 1 — OWL 2 RL materialization on an oxigraph store.
///
/// Returns inferred triples as Turtle string + count.
#[cfg(feature = "standalone")]
pub fn tier1_symbolic(
    store: &mut crate::store_oxigraph::OxigraphStore,
) -> Result<usize, Box<dyn std::error::Error>> {
    owl::materialize_owl2rl(store)
}

// ─── Tier 2: Neural ───────────────────────────────────────────────────────

/// Run tier 2 — NER extraction from text using SKOS vocabulary candidates.
///
/// Returns `CascadeTriple` entries with `Neural` origin.
/// The predicate is `rdf:type` — NER maps text mentions to SKOS concept IRIs.
#[cfg(feature = "ner")]
pub fn tier2_neural(
    engine: &crate::ner::NerEngine,
    text: &str,
    subject_iri: &str,
    candidates: &[crate::ner::Candidate],
    threshold: f32,
) -> Result<Vec<CascadeTriple>, Box<dyn std::error::Error>> {
    let results = engine.zero_shot_ner(text, candidates, threshold)?;

    Ok(results
        .into_iter()
        .map(|r| CascadeTriple {
            subject: subject_iri.to_string(),
            predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(),
            object: r.iri,
            origin: TripleOrigin::Neural,
            confidence: r.similarity,
        })
        .collect())
}

// ─── Merge ────────────────────────────────────────────────────────────────

/// Merge symbolic and neural triples, deduplicating where neural overlaps
/// with symbolic. Symbolic always wins (deterministic, confidence=1.0).
pub fn merge_dedup(
    symbolic: Vec<CascadeTriple>,
    neural: Vec<CascadeTriple>,
) -> (Vec<CascadeTriple>, usize) {
    use std::collections::HashSet;

    // Build a set of (s, p, o) from symbolic triples for O(1) lookup
    let symbolic_set: HashSet<(String, String, String)> = symbolic
        .iter()
        .map(|t| (t.subject.clone(), t.predicate.clone(), t.object.clone()))
        .collect();

    let mut merged = symbolic;
    let mut dedup_count = 0;

    for triple in neural {
        let key = (
            triple.subject.clone(),
            triple.predicate.clone(),
            triple.object.clone(),
        );
        if symbolic_set.contains(&key) {
            dedup_count += 1;
        } else {
            merged.push(triple);
        }
    }

    (merged, dedup_count)
}

// ─── Validate ─────────────────────────────────────────────────────────────

/// Convert cascade triples to Turtle for loading into a store or validation.
pub fn triples_to_turtle(triples: &[CascadeTriple]) -> String {
    let mut buf = String::new();
    for t in triples {
        // Simple N-Triples-style output (all IRIs)
        buf.push_str(&format!("<{}> <{}> <{}> .\n", t.subject, t.predicate, t.object));
    }
    buf
}

/// Run SHACL validation on the post-merge store using rudof CLI.
///
/// Exports the full store as Turtle, writes it to a temp file, and calls
/// `rudof shacl-validate` with the provided shapes. Parses the minimal output
/// to extract conformance, violation count, and warning count.
///
/// This is a quality gate — it reports violations but does not remove triples.
/// The caller decides what to do with the report.
#[cfg(feature = "standalone")]
pub fn validate_shacl(
    store: &crate::store_oxigraph::OxigraphStore,
    shapes_path: &str,
) -> Result<ShaclReport, Box<dyn std::error::Error>> {
    use std::io::Write;

    // Export all triples via CONSTRUCT (flattens named graphs)
    let turtle = store.sparql_construct("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")?;
    let mut data_file = tempfile::Builder::new().suffix(".nt").tempfile()?;
    data_file.write_all(turtle.as_bytes())?;
    data_file.flush()?;

    let data_path = data_file.path().to_str().unwrap();

    // Call rudof for minimal summary (conforms + counts)
    let minimal = std::process::Command::new("rudof")
        .args([
            "shacl-validate",
            "-s", shapes_path,
            "-t", "ntriples",
            data_path,
            "--result-format", "minimal",
        ])
        .output()?;

    let minimal_out = String::from_utf8_lossy(&minimal.stdout).to_string()
        + &String::from_utf8_lossy(&minimal.stderr);

    // Call rudof for details (full violation report)
    let details = std::process::Command::new("rudof")
        .args([
            "shacl-validate",
            "-s", shapes_path,
            "-t", "ntriples",
            data_path,
            "--result-format", "details",
        ])
        .output()?;

    let details_out = String::from_utf8_lossy(&details.stdout).to_string()
        + &String::from_utf8_lossy(&details.stderr);

    // Parse minimal output: "Conforms" or "Does not conform, N violations, M warnings"
    parse_shacl_minimal(&minimal_out, details_out)
}

/// Parse rudof `--result-format minimal` output into a ShaclReport.
fn parse_shacl_minimal(
    minimal: &str,
    details: String,
) -> Result<ShaclReport, Box<dyn std::error::Error>> {
    let line = minimal.trim();

    if line.starts_with("Conforms") {
        return Ok(ShaclReport {
            conforms: true,
            violations: 0,
            warnings: 0,
            details,
        });
    }

    // "Does not conform, 2 violations, 1 warnings"
    let mut violations = 0usize;
    let mut warnings = 0usize;

    for part in line.split(',') {
        let part = part.trim();
        if let Some(n) = part.strip_suffix(" violations").or_else(|| part.strip_suffix(" violation")) {
            violations = n.trim().parse().unwrap_or(0);
        } else if let Some(n) = part.strip_suffix(" warnings").or_else(|| part.strip_suffix(" warning")) {
            warnings = n.trim().parse().unwrap_or(0);
        }
    }

    Ok(ShaclReport {
        conforms: false,
        violations,
        warnings,
        details,
    })
}

// ─── Full Cascade ─────────────────────────────────────────────────────────

/// Run the full symbolic-first cascade on an oxigraph store.
///
/// 1. Tier 1: OWL 2 RL materialization (always runs)
/// 2. Tier 2: NER extraction (runs if engine + text provided)
/// 3. Merge: deduplicate neural vs symbolic
/// 4. Load merged triples back into the store
///
/// Returns a `CascadeResult` with full provenance.
#[cfg(feature = "standalone")]
pub fn run_cascade(
    store: &mut crate::store_oxigraph::OxigraphStore,
    #[cfg(feature = "ner")] ner_input: Option<NerInput<'_>>,
    _config: &CascadeConfig,
) -> Result<CascadeResult, Box<dyn std::error::Error>> {
    // ── Tier 1: Symbolic ──────────────────────────────────────────────
    let symbolic_count = tier1_symbolic(store)?;

    // ── Tier 2: Neural (optional) ─────────────────────────────────────
    #[cfg(feature = "ner")]
    let neural_triples = match ner_input {
        Some(input) => tier2_neural(
            input.engine,
            input.text,
            input.subject_iri,
            input.candidates,
            _config.ner_threshold,
        )?,
        None => Vec::new(),
    };

    #[cfg(not(feature = "ner"))]
    let neural_triples: Vec<CascadeTriple> = Vec::new();

    let neural_count = neural_triples.len();

    // ── Merge + Dedup ─────────────────────────────────────────────────
    // Neural triples are checked against the store (which includes tier 1
    // materializations). If a neural candidate already exists, it's deduped.
    let mut merged = Vec::new();
    let mut dedup_removed = 0;

    for triple in neural_triples {
        if triple_exists_in_store(store, &triple.subject, &triple.predicate, &triple.object) {
            dedup_removed += 1;
        } else {
            merged.push(triple);
        }
    }

    // ── Load neural-only triples into the store ───────────────────────
    if !merged.is_empty() {
        let turtle: String = merged
            .iter()
            .map(|t| format!("<{}> <{}> <{}> .\n", t.subject, t.predicate, t.object))
            .collect();
        store.load_turtle(&turtle, Some(owl::INFERRED_GRAPH))?;
    }

    // ── Tier 4: SHACL quality gate ──────────────────────────────────
    let shacl_report = match &_config.shacl_shapes {
        Some(shapes_path) => Some(validate_shacl(store, shapes_path)?),
        None => None,
    };

    // TODO: Tier 3 (LLM) — not yet implemented, deferred to v1.0

    Ok(CascadeResult {
        symbolic_count,
        neural_count,
        dedup_removed,
        shacl_report,
        triples: merged,
    })
}

/// Input for tier 2 NER in the cascade.
#[cfg(feature = "ner")]
pub struct NerInput<'a> {
    pub engine: &'a crate::ner::NerEngine,
    pub text: &'a str,
    pub subject_iri: &'a str,
    pub candidates: &'a [crate::ner::Candidate],
}

/// Check whether a triple already exists in the store (for dedup against neural).
///
/// Uses ASK query — O(1) per triple, avoids loading the entire graph.
#[cfg(feature = "standalone")]
fn triple_exists_in_store(
    store: &crate::store_oxigraph::OxigraphStore,
    subject: &str,
    predicate: &str,
    object: &str,
) -> bool {
    use crate::store::KgStore;
    let sparql = format!(
        "ASK {{ <{}> <{}> <{}> }}",
        subject, predicate, object
    );
    store.sparql_query(&sparql)
        .map(|r| r == "true")
        .unwrap_or(false)
}

#[cfg(test)]
#[cfg(feature = "standalone")]
mod tests {
    use super::*;
    use crate::store::KgStore;
    use crate::store_oxigraph::OxigraphStore;

    #[test]
    fn test_merge_dedup_symbolic_wins() {
        let symbolic = vec![CascadeTriple {
            subject: "http://ex.org/a".into(),
            predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
            object: "http://ex.org/Cat".into(),
            origin: TripleOrigin::Symbolic,
            confidence: 1.0,
        }];

        let neural = vec![
            // Duplicate of symbolic — should be removed
            CascadeTriple {
                subject: "http://ex.org/a".into(),
                predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
                object: "http://ex.org/Cat".into(),
                origin: TripleOrigin::Neural,
                confidence: 0.92,
            },
            // Unique neural triple — should survive
            CascadeTriple {
                subject: "http://ex.org/a".into(),
                predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
                object: "http://ex.org/Pet".into(),
                origin: TripleOrigin::Neural,
                confidence: 0.87,
            },
        ];

        let (merged, dedup) = merge_dedup(symbolic, neural);
        assert_eq!(dedup, 1, "One duplicate should be removed");
        assert_eq!(merged.len(), 2, "Symbolic + unique neural = 2");
        assert_eq!(merged[0].origin, TripleOrigin::Symbolic);
        assert_eq!(merged[1].origin, TripleOrigin::Neural);
    }

    #[test]
    fn test_cascade_symbolic_only() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
            @prefix : <http://example.org/> .
            :Cat rdfs:subClassOf :Animal .
            :felix a :Cat .
        "#;
        store.load_turtle(ttl, None).unwrap();

        let config = CascadeConfig::default();
        let result = run_cascade(
            &mut store,
            #[cfg(feature = "ner")]
            None,
            &config,
        )
        .unwrap();

        assert!(result.symbolic_count > 0, "OWL should infer felix a :Animal");
        assert_eq!(result.neural_count, 0);
        assert_eq!(result.dedup_removed, 0);
        assert!(result.shacl_report.is_none(), "No shapes = no SHACL report");
        assert!(result.triples.is_empty(), "No neural input = no merged triples");
    }

    #[test]
    fn test_cascade_shacl_post_merge() {
        // Write a minimal shapes file to a temp location
        let shapes_content = r#"
            @prefix sh: <http://www.w3.org/ns/shacl#> .
            @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
            @prefix : <http://example.org/> .

            :LabelShape a sh:NodeShape ;
                sh:targetClass :Cat ;
                sh:property [
                    sh:path rdfs:label ;
                    sh:minCount 1 ;
                    sh:severity sh:Violation ;
                    sh:message "Cats must have a label" ;
                ] .
        "#;
        let mut shapes_file = tempfile::NamedTempFile::new().unwrap();
        use std::io::Write;
        shapes_file.write_all(shapes_content.as_bytes()).unwrap();
        shapes_file.flush().unwrap();

        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
            @prefix : <http://example.org/> .
            :Cat rdfs:subClassOf :Animal .
            :felix a :Cat .
        "#;
        store.load_turtle(ttl, None).unwrap();

        let config = CascadeConfig {
            shacl_shapes: Some(shapes_file.path().to_str().unwrap().to_string()),
            ..Default::default()
        };

        let result = run_cascade(
            &mut store,
            #[cfg(feature = "ner")]
            None,
            &config,
        )
        .unwrap();

        let report = result.shacl_report.expect("SHACL report should be present");
        // :felix is a :Cat but has no rdfs:label → violation
        assert!(!report.conforms, "Should not conform — :felix has no label");
        assert!(report.violations > 0, "Expected at least 1 violation");
    }

    #[test]
    fn test_triple_exists_dedup() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix : <http://example.org/> .
            :felix a :Cat .
        "#;
        store.load_turtle(ttl, None).unwrap();

        // This triple exists
        assert!(triple_exists_in_store(
            &store,
            "http://example.org/felix",
            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
            "http://example.org/Cat"
        ));
        // This one doesn't
        assert!(!triple_exists_in_store(
            &store,
            "http://example.org/felix",
            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
            "http://example.org/Dog"
        ));
    }

    #[test]
    fn test_triples_to_turtle() {
        let triples = vec![CascadeTriple {
            subject: "http://ex.org/a".into(),
            predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
            object: "http://ex.org/Cat".into(),
            origin: TripleOrigin::Symbolic,
            confidence: 1.0,
        }];
        let turtle = triples_to_turtle(&triples);
        assert!(turtle.contains("<http://ex.org/a>"));
        assert!(turtle.contains("<http://ex.org/Cat>"));
    }
}