Skip to main content

mif_rh/
resolve.rs

1//! `resolve()`: classify one finding against its topic's bound ontologies.
2//!
3//! Deliberately 100% deterministic — exact string matching, regex
4//! matching, and JSON Schema validation. No embeddings anywhere in this
5//! module; see this crate's top-level documentation for why.
6
7use std::collections::{HashMap, HashSet};
8
9use regex::RegexBuilder;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use crate::catalog::Catalog;
14use crate::config::HarnessConfig;
15use crate::error::MifRhError;
16use crate::finding::Finding;
17use crate::ontology_pack::OntologyPack;
18
19/// How a finding's classification was reached, matching rht's own
20/// `ontology-map.json` basis vocabulary exactly.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Basis {
24    /// Exactly one candidate ontology matched, disambiguated by an explicit
25    /// `ontology.id` reference.
26    Declared,
27    /// Exactly one candidate ontology matched, with no ambiguity to
28    /// disambiguate.
29    Resolved,
30    /// Classified via a discovery content pattern, not an explicit type.
31    Discovery,
32    /// No typing intent and no (unambiguous) discovery match.
33    Untyped,
34    /// Typing intent present but no ontology declares the entity type, or
35    /// an explicit `ontology.id` did not resolve to a valid candidate.
36    Unresolved,
37    /// More than one bound ontology declares the entity type, with no
38    /// `ontology.id` to disambiguate.
39    Ambiguous,
40}
41
42impl Basis {
43    /// The lowercase label `ontology-map.json`/`ontology-review.sh`'s own
44    /// output uses for this basis (`"declared"`, `"resolved"`, ...).
45    #[must_use]
46    pub const fn label(self) -> &'static str {
47        match self {
48            Self::Declared => "declared",
49            Self::Resolved => "resolved",
50            Self::Discovery => "discovery",
51            Self::Untyped => "untyped",
52            Self::Unresolved => "unresolved",
53            Self::Ambiguous => "ambiguous",
54        }
55    }
56}
57
58/// One `ontology-map.json` record: the result of resolving a single
59/// finding, whether or not it classified cleanly.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct MapRecord {
62    /// The finding's id.
63    pub finding_id: String,
64    /// The finding's entity type, if one was identified (even if
65    /// unresolved/ambiguous).
66    pub entity_type: Option<String>,
67    /// The resolved ontology, as `"{id}@{version}"`, if classification
68    /// succeeded.
69    pub resolved_ontology: Option<String>,
70    /// How this record's classification was reached.
71    pub basis: Basis,
72    /// Whether the finding's `entity` payload validated against the
73    /// resolved type's schema. `false` for every basis that never reached
74    /// validation (untyped/unresolved/ambiguous).
75    pub valid: bool,
76}
77
78/// Everything needed to resolve one finding against one topic.
79pub struct ResolveContext<'a> {
80    /// The finding's topic.
81    pub topic: &'a str,
82    /// The enabled-ontologies catalog.
83    pub catalog: &'a Catalog,
84    /// The harness's topic-to-ontology bindings.
85    pub config: &'a HarnessConfig,
86    /// Every loaded ontology pack, keyed by id.
87    pub ontology_packs: &'a HashMap<String, OntologyPack>,
88}
89
90/// Resolves the set of ontologies allowed for `ctx.topic`: every core
91/// ontology, every directly bound ontology (version-checked), and their
92/// transitive `extends` ancestors.
93///
94/// # Errors
95///
96/// Returns [`MifRhError::DirectBindingInvalid`] if a topic binding names an
97/// uncataloged ontology or pins a version that does not match the
98/// catalog, or [`MifRhError::Ontology`] if resolving the `extends` chain
99/// for an allowed ontology fails (a missing ancestor or an `extends`
100/// cycle).
101pub fn build_allowed<'a>(ctx: &ResolveContext<'a>) -> Result<Vec<&'a OntologyPack>, MifRhError> {
102    let mut direct_ids: HashSet<String> = ctx.catalog.core_ids().map(str::to_string).collect();
103
104    for binding in ctx.config.topic_bindings(ctx.topic) {
105        let entry =
106            ctx.catalog
107                .find(&binding.id)
108                .ok_or_else(|| MifRhError::DirectBindingInvalid {
109                    topic: ctx.topic.to_string(),
110                    id: binding.id.clone(),
111                })?;
112        if let Some(pinned) = &binding.pinned_version
113            && *pinned != entry.version
114        {
115            return Err(MifRhError::DirectBindingInvalid {
116                topic: ctx.topic.to_string(),
117                id: binding.id.clone(),
118            });
119        }
120        direct_ids.insert(binding.id);
121    }
122
123    let metadata_map: HashMap<String, mif_ontology::OntologyMetadata> = ctx
124        .ontology_packs
125        .values()
126        .map(|pack| {
127            (
128                pack.id.clone(),
129                mif_ontology::OntologyMetadata {
130                    id: pack.id.clone(),
131                    version: pack.version.clone(),
132                    description: None,
133                    extends: pack.extends.clone(),
134                },
135            )
136        })
137        .collect();
138
139    let mut allowed_ids: HashSet<String> = HashSet::new();
140    for id in &direct_ids {
141        let chain = mif_ontology::resolve_chain(id, &metadata_map)?;
142        allowed_ids.extend(chain.into_iter().map(|m| m.id));
143    }
144    allowed_ids.extend(direct_ids);
145
146    Ok(allowed_ids
147        .iter()
148        .filter_map(|id| ctx.ontology_packs.get(id))
149        .collect())
150}
151
152fn discovery_classify(finding: &Finding, allowed: &[&OntologyPack]) -> MapRecord {
153    let text = finding.discovery_text();
154    let mut matches: Vec<(&str, &str)> = Vec::new();
155    for pack in allowed {
156        if !pack.discovery.enabled {
157            continue;
158        }
159        for pattern in &pack.discovery.patterns {
160            // file_pattern-only entries (a glob over file paths, not
161            // content) are out of scope here — resolve() only ever
162            // inspects a finding's content.
163            let Some(content_pattern) = &pattern.content_pattern else {
164                continue;
165            };
166            let Ok(re) = RegexBuilder::new(content_pattern)
167                .case_insensitive(true)
168                .build()
169            else {
170                continue;
171            };
172            if re.is_match(&text)
173                && let Some(entity_type) = pattern.suggest_entity.as_deref()
174            {
175                matches.push((entity_type, pack.id.as_str()));
176            }
177        }
178    }
179
180    let unique: HashSet<(&str, &str)> = matches.iter().copied().collect();
181    if unique.len() == 1 {
182        let (entity_type, ontology_id) = matches[0];
183        let version = allowed
184            .iter()
185            .find(|p| p.id == ontology_id)
186            .map_or_else(String::new, |p| p.version.clone());
187        MapRecord {
188            finding_id: finding.id.clone(),
189            entity_type: Some(entity_type.to_string()),
190            resolved_ontology: Some(format!("{ontology_id}@{version}")),
191            basis: Basis::Discovery,
192            valid: true,
193        }
194    } else {
195        // 0 matches, or >1 conflicting discovery matches: both fall back to
196        // untyped rather than guessing — matches rht's own discovery
197        // classifier, which never picks among ambiguous discovery hits.
198        MapRecord {
199            finding_id: finding.id.clone(),
200            entity_type: None,
201            resolved_ontology: None,
202            basis: Basis::Untyped,
203            valid: true,
204        }
205    }
206}
207
208fn unresolved(finding_id: &str, entity_type: Option<&str>) -> MapRecord {
209    MapRecord {
210        finding_id: finding_id.to_string(),
211        entity_type: entity_type.map(str::to_string),
212        resolved_ontology: None,
213        basis: Basis::Unresolved,
214        valid: false,
215    }
216}
217
218/// Builds a full, additive JSON Schema from an entity type's `{required,
219/// properties}` shape (rht's ontology packs never set `additionalProperties:
220/// false`).
221fn full_entity_schema(entity_type_schema: &Value) -> Value {
222    let required = entity_type_schema
223        .get("required")
224        .cloned()
225        .unwrap_or_else(|| json!([]));
226    let properties = entity_type_schema
227        .get("properties")
228        .cloned()
229        .unwrap_or_else(|| json!({}));
230    json!({
231        "type": "object",
232        "additionalProperties": true,
233        "required": required,
234        "properties": properties,
235    })
236}
237
238fn validate_entity(
239    entity: Option<&Value>,
240    entity_type: &str,
241    schema: &Value,
242) -> Result<bool, MifRhError> {
243    let Some(entity) = entity else {
244        return Ok(false);
245    };
246    let full_schema = full_entity_schema(schema);
247    let validator = jsonschema::options()
248        .build(&full_schema)
249        .map_err(|source| MifRhError::EntityTypeSchemaInvalid {
250            entity_type: entity_type.to_string(),
251            detail: source.to_string(),
252        })?;
253    Ok(validator.is_valid(entity))
254}
255
256/// Resolves one finding, producing an `ontology-map.json` record.
257///
258/// This is `resolve-ontology.sh`'s exact algorithm: discovery fallback when
259/// no typing intent is present, otherwise exact `entity_type` matching
260/// against the topic's allowed ontologies (core, direct bindings, and their
261/// `extends` ancestors), disambiguated by an explicit `ontology.id` when
262/// more than one candidate declares the type, followed by additive JSON
263/// Schema validation of the `entity` payload. A finding that fails to
264/// classify or fails validation is still recorded (`valid: false`), not
265/// treated as an error — see this module's [`Basis`] documentation.
266///
267/// # Errors
268///
269/// Returns [`MifRhError::DirectBindingInvalid`] or [`MifRhError::Ontology`]
270/// if the topic's own ontology bindings cannot be resolved at all (see
271/// [`build_allowed`]) — a setup problem, not a per-finding classification
272/// outcome. Returns [`MifRhError::EntityTypeSchemaInvalid`] if the resolved
273/// entity type's own schema is malformed.
274pub fn resolve_finding(
275    finding: &Finding,
276    ctx: &ResolveContext<'_>,
277) -> Result<MapRecord, MifRhError> {
278    if !finding.has_typing_intent() {
279        let allowed = build_allowed(ctx)?;
280        return Ok(discovery_classify(finding, &allowed));
281    }
282
283    let Some(entity_type) = finding.entity_type() else {
284        return Ok(unresolved(&finding.id, None));
285    };
286
287    let allowed = build_allowed(ctx)?;
288    let matches: Vec<(&OntologyPack, &crate::ontology_pack::EntityType)> = allowed
289        .iter()
290        .filter_map(|pack| {
291            pack.entity_types
292                .iter()
293                .find(|et| et.name == entity_type)
294                .map(|def| (*pack, def))
295        })
296        .collect();
297
298    let declared_ontology_id = finding.ontology.as_ref().map(|o| o.id.as_str());
299
300    let (pack, entity_type_def, basis) = match matches.len() {
301        0 => return Ok(unresolved(&finding.id, Some(entity_type))),
302        1 => {
303            let (pack, def) = matches[0];
304            match declared_ontology_id {
305                Some(oid) if oid != pack.id => {
306                    return Ok(unresolved(&finding.id, Some(entity_type)));
307                },
308                Some(_) => (pack, def, Basis::Declared),
309                None => (pack, def, Basis::Resolved),
310            }
311        },
312        _ => {
313            let Some(oid) = declared_ontology_id else {
314                return Ok(MapRecord {
315                    finding_id: finding.id.clone(),
316                    entity_type: Some(entity_type.to_string()),
317                    resolved_ontology: None,
318                    basis: Basis::Ambiguous,
319                    valid: false,
320                });
321            };
322            match matches.into_iter().find(|(pack, _)| pack.id == oid) {
323                Some((pack, def)) => (pack, def, Basis::Declared),
324                // An `ontology.id` that names none of the ambiguous candidates
325                // is still an ambiguous classification, not a fresh
326                // "unresolved" one — matches resolve-ontology.sh's own
327                // `mcount > 1` branch, which records "ambiguous" whether the
328                // declared id is absent or simply doesn't match.
329                None => {
330                    return Ok(MapRecord {
331                        finding_id: finding.id.clone(),
332                        entity_type: Some(entity_type.to_string()),
333                        resolved_ontology: None,
334                        basis: Basis::Ambiguous,
335                        valid: false,
336                    });
337                },
338            }
339        },
340    };
341
342    let valid = validate_entity(
343        finding.entity.as_ref(),
344        entity_type,
345        &entity_type_def.schema,
346    )?;
347
348    Ok(MapRecord {
349        finding_id: finding.id.clone(),
350        entity_type: Some(entity_type.to_string()),
351        resolved_ontology: Some(format!("{}@{}", pack.id, pack.version)),
352        basis,
353        valid,
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use std::collections::HashMap;
360
361    use serde_json::json;
362
363    use super::{Basis, ResolveContext, resolve_finding};
364    use crate::catalog::{Catalog, CatalogEntry};
365    use crate::config::{HarnessConfig, TopicConfig};
366    use crate::finding::Finding;
367    use crate::ontology_pack::parse_pack;
368
369    fn finding_from_json(value: serde_json::Value) -> Finding {
370        serde_json::from_value(value).unwrap()
371    }
372
373    fn edu_fixture_pack() -> crate::ontology_pack::OntologyPack {
374        parse_pack(
375            "
376ontology:
377  id: edu-fixture
378  version: \"0.1.0\"
379entity_types:
380  - name: title
381    schema:
382      required: [name, isbn]
383      properties: {name: {type: string}, isbn: {type: string}}
384discovery:
385  enabled: true
386  patterns:
387    - content_pattern: \"\\\\b(ISBN|textbook)\\\\b\"
388      suggest_entity: title
389",
390            "edu-fixture.yaml",
391        )
392        .unwrap()
393    }
394
395    fn ctx_fixture<'a>(
396        packs: &'a HashMap<String, crate::ontology_pack::OntologyPack>,
397        catalog: &'a Catalog,
398        config: &'a HarnessConfig,
399        topic: &'a str,
400    ) -> ResolveContext<'a> {
401        ResolveContext {
402            topic,
403            catalog,
404            config,
405            ontology_packs: packs,
406        }
407    }
408
409    fn edu_setup() -> (
410        HashMap<String, crate::ontology_pack::OntologyPack>,
411        Catalog,
412        HarnessConfig,
413    ) {
414        let mut packs = HashMap::new();
415        packs.insert("edu-fixture".to_string(), edu_fixture_pack());
416        let catalog = Catalog {
417            ontologies: vec![CatalogEntry {
418                id: "edu-fixture".to_string(),
419                version: "0.1.0".to_string(),
420                source: None,
421                core: false,
422            }],
423        };
424        let config = HarnessConfig {
425            topics: vec![TopicConfig {
426                id: "edu".to_string(),
427                ontologies: vec!["edu-fixture".to_string()],
428            }],
429        };
430        (packs, catalog, config)
431    }
432
433    #[test]
434    fn resolves_a_declared_type_and_validates_the_entity() {
435        let (packs, catalog, config) = edu_setup();
436        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
437        let finding = finding_from_json(json!({
438            "@id": "f-good",
439            "entity": {"name": "Algebra I", "entity_type": "title", "isbn": "9780000000002"}
440        }));
441
442        let record = resolve_finding(&finding, &ctx).unwrap();
443        assert_eq!(record.basis, Basis::Resolved);
444        assert!(record.valid);
445        assert_eq!(
446            record.resolved_ontology.as_deref(),
447            Some("edu-fixture@0.1.0")
448        );
449    }
450
451    #[test]
452    fn extra_properties_are_allowed_additively() {
453        let (packs, catalog, config) = edu_setup();
454        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
455        let finding = finding_from_json(json!({
456            "@id": "f-extra",
457            "entity": {"name": "Algebra I", "entity_type": "title", "isbn": "9780000000002", "vibe": "x"}
458        }));
459
460        let record = resolve_finding(&finding, &ctx).unwrap();
461        assert!(record.valid);
462    }
463
464    #[test]
465    fn missing_required_field_fails_validation_but_still_records() {
466        let (packs, catalog, config) = edu_setup();
467        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
468        let finding = finding_from_json(json!({
469            "@id": "f-missing",
470            "entity": {"entity_type": "title"}
471        }));
472
473        let record = resolve_finding(&finding, &ctx).unwrap();
474        assert_eq!(record.basis, Basis::Resolved);
475        assert!(!record.valid);
476    }
477
478    #[test]
479    fn undeclared_type_is_unresolved() {
480        let (packs, catalog, config) = edu_setup();
481        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
482        let finding = finding_from_json(json!({
483            "@id": "f-undecl",
484            "entity": {"entity_type": "not-a-type"}
485        }));
486
487        let record = resolve_finding(&finding, &ctx).unwrap();
488        assert_eq!(record.basis, Basis::Unresolved);
489        assert!(!record.valid);
490    }
491
492    #[test]
493    fn untyped_finding_with_no_discovery_match_is_untyped() {
494        let (packs, catalog, config) = edu_setup();
495        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
496        let finding = finding_from_json(json!({"@id": "f-untyped", "content": "nothing special"}));
497
498        let record = resolve_finding(&finding, &ctx).unwrap();
499        assert_eq!(record.basis, Basis::Untyped);
500        assert!(record.valid);
501        assert_eq!(record.entity_type, None);
502    }
503
504    #[test]
505    fn discovery_pattern_classifies_an_untyped_finding() {
506        let (packs, catalog, config) = edu_setup();
507        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
508        let finding = finding_from_json(
509            json!({"@id": "f-discovery", "content": "a great textbook, ISBN included"}),
510        );
511
512        let record = resolve_finding(&finding, &ctx).unwrap();
513        assert_eq!(record.basis, Basis::Discovery);
514        assert_eq!(record.entity_type.as_deref(), Some("title"));
515        assert!(record.valid);
516    }
517
518    #[test]
519    fn declared_type_on_an_unbound_topic_only_resolves_core() {
520        let (packs, catalog, config) = edu_setup();
521        let ctx = ctx_fixture(&packs, &catalog, &config, "bare");
522        let finding = finding_from_json(json!({
523            "@id": "f-good",
524            "entity": {"name": "x", "entity_type": "title", "isbn": "y"}
525        }));
526
527        let record = resolve_finding(&finding, &ctx).unwrap();
528        assert_eq!(record.basis, Basis::Unresolved);
529    }
530
531    #[test]
532    fn ambiguous_type_across_two_bound_ontologies_without_explicit_id() {
533        let mut packs = HashMap::new();
534        packs.insert(
535            "a".to_string(),
536            parse_pack(
537                "ontology:\n  id: a\n  version: \"1.0.0\"\nentity_types:\n  - name: technology\n    schema: {}\n",
538                "a.yaml",
539            )
540            .unwrap(),
541        );
542        packs.insert(
543            "b".to_string(),
544            parse_pack(
545                "ontology:\n  id: b\n  version: \"1.0.0\"\nentity_types:\n  - name: technology\n    schema: {}\n",
546                "b.yaml",
547            )
548            .unwrap(),
549        );
550        let catalog = Catalog {
551            ontologies: vec![
552                CatalogEntry {
553                    id: "a".to_string(),
554                    version: "1.0.0".to_string(),
555                    source: None,
556                    core: false,
557                },
558                CatalogEntry {
559                    id: "b".to_string(),
560                    version: "1.0.0".to_string(),
561                    source: None,
562                    core: false,
563                },
564            ],
565        };
566        let config = HarnessConfig {
567            topics: vec![TopicConfig {
568                id: "eng".to_string(),
569                ontologies: vec!["a".to_string(), "b".to_string()],
570            }],
571        };
572        let ctx = ctx_fixture(&packs, &catalog, &config, "eng");
573
574        let ambiguous = finding_from_json(json!({
575            "@id": "f-amb",
576            "entity": {"entity_type": "technology"}
577        }));
578        let record = resolve_finding(&ambiguous, &ctx).unwrap();
579        assert_eq!(record.basis, Basis::Ambiguous);
580
581        let disambiguated = finding_from_json(json!({
582            "@id": "f-disambig",
583            "entity": {"entity_type": "technology"},
584            "ontology": {"id": "b"}
585        }));
586        let record = resolve_finding(&disambiguated, &ctx).unwrap();
587        assert_eq!(record.basis, Basis::Declared);
588        assert_eq!(record.resolved_ontology.as_deref(), Some("b@1.0.0"));
589
590        // An explicit `ontology.id` that names neither candidate is STILL
591        // ambiguous, not unresolved — matches resolve-ontology.sh's
592        // `mcount > 1` branch (`grep -qx "$oid" || record ... "ambiguous"`),
593        // which never falls back to "unresolved" once there are 2+ matches.
594        let wrong_id = finding_from_json(json!({
595            "@id": "f-wrong-id",
596            "entity": {"entity_type": "technology"},
597            "ontology": {"id": "c"}
598        }));
599        let record = resolve_finding(&wrong_id, &ctx).unwrap();
600        assert_eq!(record.basis, Basis::Ambiguous);
601        assert!(!record.valid);
602    }
603
604    #[test]
605    fn file_pattern_only_discovery_entry_is_skipped_not_treated_as_match_all() {
606        // Matches rht's own `software-engineering.ontology.yaml`, which mixes
607        // file_pattern-only entries (no `content_pattern`) with real content
608        // patterns. A file_pattern-only entry must never be treated as an
609        // empty-string content regex (which would match every finding).
610        let mut packs = HashMap::new();
611        packs.insert(
612            "se-fixture".to_string(),
613            parse_pack(
614                "
615ontology:
616  id: se-fixture
617  version: \"0.1.0\"
618entity_types:
619  - name: decision
620    schema: {}
621discovery:
622  enabled: true
623  patterns:
624    - file_pattern: \"*.md\"
625    - content_pattern: \"\\\\bADR\\\\b\"
626      suggest_entity: decision
627",
628                "se-fixture.yaml",
629            )
630            .unwrap(),
631        );
632        let catalog = Catalog {
633            ontologies: vec![CatalogEntry {
634                id: "se-fixture".to_string(),
635                version: "0.1.0".to_string(),
636                source: None,
637                core: false,
638            }],
639        };
640        let config = HarnessConfig {
641            topics: vec![TopicConfig {
642                id: "eng".to_string(),
643                ontologies: vec!["se-fixture".to_string()],
644            }],
645        };
646        let ctx = ctx_fixture(&packs, &catalog, &config, "eng");
647
648        // Content that shares no words with the real content_pattern, and
649        // would only match if the file_pattern-only entry were incorrectly
650        // treated as an always-matching content regex.
651        let finding = finding_from_json(json!({
652            "@id": "f-no-match",
653            "content": "totally unrelated prose about lunch"
654        }));
655        let record = resolve_finding(&finding, &ctx).unwrap();
656        assert_eq!(record.basis, Basis::Untyped);
657        assert_eq!(record.entity_type, None);
658    }
659
660    #[test]
661    fn topic_binding_an_uncataloged_ontology_is_a_hard_error() {
662        let (packs, catalog, _config) = edu_setup();
663        let config = HarnessConfig {
664            topics: vec![TopicConfig {
665                id: "edu".to_string(),
666                ontologies: vec!["not-cataloged".to_string()],
667            }],
668        };
669        let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
670        let finding = finding_from_json(json!({"@id": "f-x", "content": "x"}));
671
672        let error = resolve_finding(&finding, &ctx).unwrap_err();
673        assert!(matches!(
674            error,
675            super::MifRhError::DirectBindingInvalid { .. }
676        ));
677    }
678}