Skip to main content

rete_core/
reason.rs

1//! A prototype forward-chaining OWL RL / RDFS rule reasoner.
2//!
3//! This is **not** a complete OWL DL/RL reasoner — it is a deliberately small,
4//! transparent subset chosen to support a causal-modeling coherence check:
5//! materialize the obvious RDFS/OWL entailments to a fixpoint, then flag
6//! "incoherent points" (logical contradictions) such as disjoint-class
7//! violations and functional-property clashes.
8//!
9//! Triples are canonical N-Triples token strings (`<iri>`, `"lit"`, `_:b`, …),
10//! matched by exact string equality — the same representation the rest of the
11//! crate uses. See `docs/reasoning.md` for the rule tables and scope.
12//!
13//! ## Entailment rules (materialized to fixpoint)
14//! - `rdfs:subClassOf` transitivity
15//! - type propagation across `rdfs:subClassOf`
16//! - `rdfs:subPropertyOf` (property inheritance + transitivity)
17//! - `rdfs:domain` / `rdfs:range` typing
18//! - `owl:inverseOf` (both directions)
19//! - `owl:SymmetricProperty`
20//! - `owl:TransitiveProperty`
21//!
22//! ## Inconsistency rules (detected after materialization)
23//! - disjoint-class membership (`owl:disjointWith`)
24//! - `owl:sameAs` / `owl:differentFrom` contradiction
25//! - `owl:FunctionalProperty` clash
26//! - `owl:Nothing` membership
27
28use std::collections::{BTreeMap, HashSet};
29
30/// Version tag of this reasoner's rule set. Stamped into a baked coherence card so
31/// a `coherent: true` can never be misread as a guarantee from a *different* set of
32/// rules. **Bump this whenever `materialize`/`detect_inconsistencies` changes** (a
33/// rule added/removed/altered), so `rete reason --verify-card` rejects a stale stamp.
34pub const REASON_RULESET: &str = "owl-rl-subset/v1";
35
36// --- Vocabulary IRIs, as canonical N-Triples tokens -------------------------
37
38const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
39const RDFS_SUBCLASS_OF: &str = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
40const RDFS_SUBPROPERTY_OF: &str = "<http://www.w3.org/2000/01/rdf-schema#subPropertyOf>";
41const RDFS_DOMAIN: &str = "<http://www.w3.org/2000/01/rdf-schema#domain>";
42const RDFS_RANGE: &str = "<http://www.w3.org/2000/01/rdf-schema#range>";
43
44const OWL_INVERSE_OF: &str = "<http://www.w3.org/2002/07/owl#inverseOf>";
45const OWL_SYMMETRIC_PROPERTY: &str = "<http://www.w3.org/2002/07/owl#SymmetricProperty>";
46const OWL_TRANSITIVE_PROPERTY: &str = "<http://www.w3.org/2002/07/owl#TransitiveProperty>";
47const OWL_FUNCTIONAL_PROPERTY: &str = "<http://www.w3.org/2002/07/owl#FunctionalProperty>";
48const OWL_DISJOINT_WITH: &str = "<http://www.w3.org/2002/07/owl#disjointWith>";
49const OWL_SAME_AS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
50const OWL_DIFFERENT_FROM: &str = "<http://www.w3.org/2002/07/owl#differentFrom>";
51const OWL_NOTHING: &str = "<http://www.w3.org/2002/07/owl#Nothing>";
52
53/// One detected incoherent point (a logical contradiction in the graph).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Inconsistency {
56    /// A short stable category, e.g. `"disjoint-classes"`.
57    pub kind: &'static str,
58    /// A human-readable description naming the offending terms.
59    pub detail: String,
60}
61
62/// The result of reasoning over a base graph.
63#[derive(Debug, Clone, Default)]
64#[must_use]
65pub struct Reasoning {
66    /// Newly entailed triples (those not already present in the base graph).
67    pub inferred: Vec<(String, String, String)>,
68    /// Detected incoherent points, computed after materialization.
69    pub inconsistencies: Vec<Inconsistency>,
70}
71
72type Triple = (String, String, String);
73
74/// Forward-chain the supported RDFS/OWL rules to a fixpoint, then scan for
75/// inconsistencies over the closed graph. `inferred` excludes triples already
76/// present in `base_triples`.
77pub fn reason(base_triples: &[Triple]) -> Reasoning {
78    // The working set is the deductive closure; `base` lets us report only the
79    // *newly* entailed triples at the end.
80    let base: HashSet<Triple> = base_triples.iter().cloned().collect();
81    let mut all: HashSet<Triple> = base.clone();
82
83    materialize(&mut all);
84
85    let mut inferred: Vec<Triple> = all.iter().filter(|t| !base.contains(*t)).cloned().collect();
86    inferred.sort();
87
88    let inconsistencies = detect_inconsistencies(&all);
89
90    Reasoning {
91        inferred,
92        inconsistencies,
93    }
94}
95
96/// Iterate the entailment rules until no new triple is produced (fixpoint).
97fn materialize(all: &mut HashSet<Triple>) {
98    loop {
99        // Snapshot to iterate while we collect additions; apply at round end so
100        // pattern matching always sees a consistent set.
101        let snapshot: Vec<Triple> = all.iter().cloned().collect();
102        let mut new: Vec<Triple> = Vec::new();
103
104        // Pre-bucket the schema axioms we need to pair with data triples.
105        let subclass: Vec<(&str, &str)> = snapshot
106            .iter()
107            .filter(|(_, p, _)| p == RDFS_SUBCLASS_OF)
108            .map(|(s, _, o)| (s.as_str(), o.as_str()))
109            .collect();
110        let subprop: Vec<(&str, &str)> = snapshot
111            .iter()
112            .filter(|(_, p, _)| p == RDFS_SUBPROPERTY_OF)
113            .map(|(s, _, o)| (s.as_str(), o.as_str()))
114            .collect();
115        let domains: Vec<(&str, &str)> = snapshot
116            .iter()
117            .filter(|(_, p, _)| p == RDFS_DOMAIN)
118            .map(|(s, _, o)| (s.as_str(), o.as_str()))
119            .collect();
120        let ranges: Vec<(&str, &str)> = snapshot
121            .iter()
122            .filter(|(_, p, _)| p == RDFS_RANGE)
123            .map(|(s, _, o)| (s.as_str(), o.as_str()))
124            .collect();
125        let inverses: Vec<(&str, &str)> = snapshot
126            .iter()
127            .filter(|(_, p, _)| p == OWL_INVERSE_OF)
128            .map(|(s, _, o)| (s.as_str(), o.as_str()))
129            .collect();
130        let symmetric: HashSet<&str> = snapshot
131            .iter()
132            .filter(|(_, p, o)| p == RDF_TYPE && o == OWL_SYMMETRIC_PROPERTY)
133            .map(|(s, _, _)| s.as_str())
134            .collect();
135        let transitive: HashSet<&str> = snapshot
136            .iter()
137            .filter(|(_, p, o)| p == RDF_TYPE && o == OWL_TRANSITIVE_PROPERTY)
138            .map(|(s, _, _)| s.as_str())
139            .collect();
140
141        let emit = |s: &str, p: &str, o: &str, new: &mut Vec<Triple>| {
142            let t = (s.to_string(), p.to_string(), o.to_string());
143            if !all.contains(&t) {
144                new.push(t);
145            }
146        };
147
148        for (s, p, o) in &snapshot {
149            let (s, p, o) = (s.as_str(), p.as_str(), o.as_str());
150
151            // rdfs:subClassOf transitivity: c ⊑ d . d ⊑ e ⇒ c ⊑ e
152            if p == RDFS_SUBCLASS_OF {
153                for (d2, e) in &subclass {
154                    if *d2 == o {
155                        emit(s, RDFS_SUBCLASS_OF, e, &mut new);
156                    }
157                }
158            }
159
160            // rdfs:subPropertyOf transitivity: p ⊑ q . q ⊑ r ⇒ p ⊑ r
161            if p == RDFS_SUBPROPERTY_OF {
162                for (q2, r) in &subprop {
163                    if *q2 == o {
164                        emit(s, RDFS_SUBPROPERTY_OF, r, &mut new);
165                    }
166                }
167            }
168
169            if p == RDF_TYPE {
170                // type propagation: x a c . c ⊑ d ⇒ x a d
171                for (c, d) in &subclass {
172                    if *c == o {
173                        emit(s, RDF_TYPE, d, &mut new);
174                    }
175                }
176            } else {
177                // rdfs:subPropertyOf: p ⊑ q . x p y ⇒ x q y
178                for (p2, q) in &subprop {
179                    if *p2 == p {
180                        emit(s, q, o, &mut new);
181                    }
182                }
183                // rdfs:domain: p domain c . x p y ⇒ x a c
184                for (pr, c) in &domains {
185                    if *pr == p {
186                        emit(s, RDF_TYPE, c, &mut new);
187                    }
188                }
189                // rdfs:range: p range c . x p y ⇒ y a c
190                for (pr, c) in &ranges {
191                    if *pr == p {
192                        emit(o, RDF_TYPE, c, &mut new);
193                    }
194                }
195                // owl:inverseOf: p inverseOf q . x p y ⇒ y q x (both directions)
196                for (a, b) in &inverses {
197                    if *a == p {
198                        emit(o, b, s, &mut new);
199                    }
200                    if *b == p {
201                        emit(o, a, s, &mut new);
202                    }
203                }
204                // owl:SymmetricProperty: x p y ⇒ y p x
205                if symmetric.contains(p) {
206                    emit(o, p, s, &mut new);
207                }
208                // owl:TransitiveProperty: x p y . y p z ⇒ x p z
209                if transitive.contains(p) {
210                    for (s2, p2, z) in &snapshot {
211                        if p2 == p && s2 == o {
212                            emit(s, p, z, &mut new);
213                        }
214                    }
215                }
216            }
217        }
218
219        if new.is_empty() {
220            break;
221        }
222        for t in new {
223            all.insert(t);
224        }
225    }
226}
227
228/// Scan the materialized graph for incoherent points.
229fn detect_inconsistencies(all: &HashSet<Triple>) -> Vec<Inconsistency> {
230    let mut out: Vec<Inconsistency> = Vec::new();
231
232    // Index helpers over the closed graph.
233    let types: Vec<(&str, &str)> = all
234        .iter()
235        .filter(|(_, p, _)| p == RDF_TYPE)
236        .map(|(s, _, o)| (s.as_str(), o.as_str()))
237        .collect();
238
239    // Disjoint pairs, recorded symmetrically so direction doesn't matter.
240    let mut disjoint: HashSet<(&str, &str)> = HashSet::new();
241    for (s, p, o) in all {
242        if p == OWL_DISJOINT_WITH {
243            disjoint.insert((s.as_str(), o.as_str()));
244            disjoint.insert((o.as_str(), s.as_str()));
245        }
246    }
247
248    // sameAs / differentFrom pairs (symmetric).
249    let mut same_as: HashSet<(&str, &str)> = HashSet::new();
250    let mut different_from: HashSet<(&str, &str)> = HashSet::new();
251    for (s, p, o) in all {
252        if p == OWL_SAME_AS {
253            same_as.insert((s.as_str(), o.as_str()));
254            same_as.insert((o.as_str(), s.as_str()));
255        } else if p == OWL_DIFFERENT_FROM {
256            different_from.insert((s.as_str(), o.as_str()));
257            different_from.insert((o.as_str(), s.as_str()));
258        }
259    }
260
261    // --- Disjoint classes: x a c . x a d . c disjointWith d ------------------
262    // Group each individual's class set, then check only the unordered class pairs
263    // *within one individual* — O(types + Σ classesₓ²), not O(types²) over the whole
264    // graph (the naive double loop was quadratic in the type-triple count).
265    let mut classes_of: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
266    for (x, c) in &types {
267        classes_of.entry(*x).or_default().push(*c);
268    }
269    let mut seen_disjoint: HashSet<(&str, &str, &str)> = HashSet::new();
270    for (x, cs) in &classes_of {
271        for i in 0..cs.len() {
272            for j in (i + 1)..cs.len() {
273                let (c, d) = (cs[i], cs[j]);
274                if c == d || !disjoint.contains(&(c, d)) {
275                    continue;
276                }
277                // Canonicalize the (x, class-pair) so we report each clash once.
278                let (lo, hi) = if c < d { (c, d) } else { (d, c) };
279                if seen_disjoint.insert((x, lo, hi)) {
280                    out.push(Inconsistency {
281                        kind: "disjoint-classes",
282                        detail: format!(
283                            "{x} is typed as both {lo} and {hi}, which are owl:disjointWith"
284                        ),
285                    });
286                }
287            }
288        }
289    }
290
291    // --- sameAs / differentFrom contradiction --------------------------------
292    let mut seen_same: HashSet<(&str, &str)> = HashSet::new();
293    for (x, y) in &same_as {
294        if different_from.contains(&(*x, *y)) {
295            let (lo, hi) = if x < y { (*x, *y) } else { (*y, *x) };
296            if seen_same.insert((lo, hi)) {
297                out.push(Inconsistency {
298                    kind: "sameas-differentfrom",
299                    detail: format!("{lo} and {hi} are both owl:sameAs and owl:differentFrom"),
300                });
301            }
302        }
303    }
304
305    // --- Functional property: p a FunctionalProperty . x p y . x p z . y≠z ----
306    let functional: HashSet<&str> = all
307        .iter()
308        .filter(|(_, p, o)| p == RDF_TYPE && o == OWL_FUNCTIONAL_PROPERTY)
309        .map(|(s, _, _)| s.as_str())
310        .collect();
311    if !functional.is_empty() {
312        // Group the values of each functional (subject, predicate), then check the
313        // distinct value pairs *within* one (subject, predicate) — O(Σ valuesₛₚ²),
314        // not O(all²) over every triple pair.
315        let mut values_of: BTreeMap<(&str, &str), Vec<&str>> = BTreeMap::new();
316        for (s, p, o) in all {
317            if functional.contains(p.as_str()) {
318                values_of
319                    .entry((s.as_str(), p.as_str()))
320                    .or_default()
321                    .push(o.as_str());
322            }
323        }
324        let mut seen_func: HashSet<(&str, &str, &str, &str)> = HashSet::new();
325        for ((s, p), vals) in &values_of {
326            for i in 0..vals.len() {
327                for j in (i + 1)..vals.len() {
328                    let (o, o2) = (vals[i], vals[j]);
329                    // Not a clash if the two values are equal or asserted owl:sameAs.
330                    if o == o2 || same_as.contains(&(o, o2)) {
331                        continue;
332                    }
333                    let (lo, hi) = if o < o2 { (o, o2) } else { (o2, o) };
334                    if seen_func.insert((s, p, lo, hi)) {
335                        out.push(Inconsistency {
336                            kind: "functional-property",
337                            detail: format!(
338                                "functional property {p} on {s} has distinct values {lo} and {hi}"
339                            ),
340                        });
341                    }
342                }
343            }
344        }
345    }
346
347    // --- owl:Nothing membership ----------------------------------------------
348    for (x, c) in &types {
349        if *c == OWL_NOTHING {
350            out.push(Inconsistency {
351                kind: "owl-nothing",
352                detail: format!("{x} is a member of owl:Nothing (the empty class)"),
353            });
354        }
355    }
356
357    out.sort_by(|a, b| (a.kind, &a.detail).cmp(&(b.kind, &b.detail)));
358    out
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn t(s: &str, p: &str, o: &str) -> Triple {
366        (s.to_string(), p.to_string(), o.to_string())
367    }
368
369    /// Convenience: does the inferred set contain this triple?
370    fn inferred_has(r: &Reasoning, s: &str, p: &str, o: &str) -> bool {
371        r.inferred.contains(&t(s, p, o))
372    }
373
374    // --- Entailment rules ----------------------------------------------------
375
376    #[test]
377    fn subclass_transitivity() {
378        let base = vec![
379            t("<c>", RDFS_SUBCLASS_OF, "<d>"),
380            t("<d>", RDFS_SUBCLASS_OF, "<e>"),
381        ];
382        let r = reason(&base);
383        assert!(inferred_has(&r, "<c>", RDFS_SUBCLASS_OF, "<e>"));
384    }
385
386    #[test]
387    fn type_propagation_over_subclass() {
388        let base = vec![t("<x>", RDF_TYPE, "<c>"), t("<c>", RDFS_SUBCLASS_OF, "<d>")];
389        let r = reason(&base);
390        assert!(inferred_has(&r, "<x>", RDF_TYPE, "<d>"));
391    }
392
393    #[test]
394    fn subproperty_propagation_and_transitivity() {
395        let base = vec![
396            t("<p>", RDFS_SUBPROPERTY_OF, "<q>"),
397            t("<q>", RDFS_SUBPROPERTY_OF, "<r>"),
398            t("<x>", "<p>", "<y>"),
399        ];
400        let r = reason(&base);
401        // p ⊑ r (transitive), and x q y / x r y (inheritance).
402        assert!(inferred_has(&r, "<p>", RDFS_SUBPROPERTY_OF, "<r>"));
403        assert!(inferred_has(&r, "<x>", "<q>", "<y>"));
404        assert!(inferred_has(&r, "<x>", "<r>", "<y>"));
405    }
406
407    #[test]
408    fn domain_typing() {
409        let base = vec![t("<p>", RDFS_DOMAIN, "<C>"), t("<x>", "<p>", "<y>")];
410        let r = reason(&base);
411        assert!(inferred_has(&r, "<x>", RDF_TYPE, "<C>"));
412    }
413
414    #[test]
415    fn range_typing() {
416        let base = vec![t("<p>", RDFS_RANGE, "<C>"), t("<x>", "<p>", "<y>")];
417        let r = reason(&base);
418        assert!(inferred_has(&r, "<y>", RDF_TYPE, "<C>"));
419    }
420
421    #[test]
422    fn inverse_of_both_directions() {
423        let base = vec![
424            t("<p>", OWL_INVERSE_OF, "<q>"),
425            t("<x>", "<p>", "<y>"),
426            t("<a>", "<q>", "<b>"),
427        ];
428        let r = reason(&base);
429        assert!(inferred_has(&r, "<y>", "<q>", "<x>"));
430        assert!(inferred_has(&r, "<b>", "<p>", "<a>"));
431    }
432
433    #[test]
434    fn symmetric_property() {
435        let base = vec![
436            t("<p>", RDF_TYPE, OWL_SYMMETRIC_PROPERTY),
437            t("<x>", "<p>", "<y>"),
438        ];
439        let r = reason(&base);
440        assert!(inferred_has(&r, "<y>", "<p>", "<x>"));
441    }
442
443    #[test]
444    fn transitive_property() {
445        let base = vec![
446            t("<p>", RDF_TYPE, OWL_TRANSITIVE_PROPERTY),
447            t("<x>", "<p>", "<y>"),
448            t("<y>", "<p>", "<z>"),
449        ];
450        let r = reason(&base);
451        assert!(inferred_has(&r, "<x>", "<p>", "<z>"));
452    }
453
454    // --- Inconsistency rules -------------------------------------------------
455
456    #[test]
457    fn disjoint_classes_detected() {
458        let base = vec![
459            t("<C>", OWL_DISJOINT_WITH, "<D>"),
460            t("<x>", RDF_TYPE, "<C>"),
461            t("<x>", RDF_TYPE, "<D>"),
462        ];
463        let r = reason(&base);
464        assert!(r
465            .inconsistencies
466            .iter()
467            .any(|i| i.kind == "disjoint-classes"));
468    }
469
470    #[test]
471    fn disjoint_classes_via_subclass_propagation() {
472        // The clash only surfaces after type propagation: x is a C, C ⊑ D,
473        // and D is disjoint with E, x is an E.
474        let base = vec![
475            t("<C>", RDFS_SUBCLASS_OF, "<D>"),
476            t("<D>", OWL_DISJOINT_WITH, "<E>"),
477            t("<x>", RDF_TYPE, "<C>"),
478            t("<x>", RDF_TYPE, "<E>"),
479        ];
480        let r = reason(&base);
481        assert!(
482            r.inconsistencies
483                .iter()
484                .any(|i| i.kind == "disjoint-classes"),
485            "expected a disjoint-classes clash exposed by subClassOf propagation"
486        );
487    }
488
489    #[test]
490    fn sameas_differentfrom_detected() {
491        let base = vec![
492            t("<x>", OWL_SAME_AS, "<y>"),
493            t("<y>", OWL_DIFFERENT_FROM, "<x>"),
494        ];
495        let r = reason(&base);
496        assert!(r
497            .inconsistencies
498            .iter()
499            .any(|i| i.kind == "sameas-differentfrom"));
500    }
501
502    #[test]
503    fn functional_property_clash_detected() {
504        let base = vec![
505            t("<p>", RDF_TYPE, OWL_FUNCTIONAL_PROPERTY),
506            t("<x>", "<p>", "<y>"),
507            t("<x>", "<p>", "<z>"),
508        ];
509        let r = reason(&base);
510        assert!(r
511            .inconsistencies
512            .iter()
513            .any(|i| i.kind == "functional-property"));
514    }
515
516    #[test]
517    fn functional_property_sameas_is_coherent() {
518        // Two values that are owl:sameAs are NOT a functional clash.
519        let base = vec![
520            t("<p>", RDF_TYPE, OWL_FUNCTIONAL_PROPERTY),
521            t("<x>", "<p>", "<y>"),
522            t("<x>", "<p>", "<z>"),
523            t("<y>", OWL_SAME_AS, "<z>"),
524        ];
525        let r = reason(&base);
526        assert!(!r
527            .inconsistencies
528            .iter()
529            .any(|i| i.kind == "functional-property"));
530    }
531
532    #[test]
533    fn owl_nothing_detected() {
534        let base = vec![t("<x>", RDF_TYPE, OWL_NOTHING)];
535        let r = reason(&base);
536        assert!(r.inconsistencies.iter().any(|i| i.kind == "owl-nothing"));
537    }
538
539    #[test]
540    fn coherent_graph_has_no_inconsistencies() {
541        let base = vec![
542            t("<C>", RDFS_SUBCLASS_OF, "<D>"),
543            t("<x>", RDF_TYPE, "<C>"),
544            t("<p>", RDF_TYPE, OWL_TRANSITIVE_PROPERTY),
545            t("<x>", "<p>", "<y>"),
546            t("<y>", "<p>", "<z>"),
547        ];
548        let r = reason(&base);
549        assert!(
550            r.inconsistencies.is_empty(),
551            "expected coherent graph, got {:?}",
552            r.inconsistencies
553        );
554        // Sanity: it still entailed something.
555        assert!(!r.inferred.is_empty());
556    }
557
558    #[test]
559    fn inferred_excludes_base_triples() {
560        let base = vec![t("<x>", RDF_TYPE, "<c>"), t("<c>", RDFS_SUBCLASS_OF, "<d>")];
561        let r = reason(&base);
562        // The base triples must not appear in `inferred`.
563        for b in &base {
564            assert!(!r.inferred.contains(b));
565        }
566        assert!(inferred_has(&r, "<x>", RDF_TYPE, "<d>"));
567    }
568
569    #[test]
570    fn end_to_end_small_ontology() {
571        // A tiny causal ontology + data: Cause ⊑ Event, :causes is transitive,
572        // Healthy disjointWith Sick, and a patient typed as both.
573        let cause = "<http://ex/Cause>";
574        let event = "<http://ex/Event>";
575        let causes = "<http://ex/causes>";
576        let healthy = "<http://ex/Healthy>";
577        let sick = "<http://ex/Sick>";
578        let base = vec![
579            t(cause, RDFS_SUBCLASS_OF, event),
580            t(causes, RDF_TYPE, OWL_TRANSITIVE_PROPERTY),
581            t(healthy, OWL_DISJOINT_WITH, sick),
582            t("<http://ex/a>", RDF_TYPE, cause),
583            t("<http://ex/a>", causes, "<http://ex/b>"),
584            t("<http://ex/b>", causes, "<http://ex/c>"),
585            t("<http://ex/p>", RDF_TYPE, healthy),
586            t("<http://ex/p>", RDF_TYPE, sick),
587        ];
588        let r = reason(&base);
589        // Entailments: a is an Event; a causes c (transitivity).
590        assert!(inferred_has(&r, "<http://ex/a>", RDF_TYPE, event));
591        assert!(inferred_has(&r, "<http://ex/a>", causes, "<http://ex/c>"));
592        // Incoherent point: p is both Healthy and Sick.
593        assert!(r
594            .inconsistencies
595            .iter()
596            .any(|i| i.kind == "disjoint-classes" && i.detail.contains("http://ex/p")));
597    }
598}