1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Basis {
24 Declared,
27 Resolved,
30 Discovery,
32 Untyped,
34 Unresolved,
37 Ambiguous,
40}
41
42impl Basis {
43 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct MapRecord {
62 pub finding_id: String,
64 pub entity_type: Option<String>,
67 pub resolved_ontology: Option<String>,
70 pub basis: Basis,
72 pub valid: bool,
76}
77
78pub struct ResolveContext<'a> {
80 pub topic: &'a str,
82 pub catalog: &'a Catalog,
84 pub config: &'a HarnessConfig,
86 pub ontology_packs: &'a HashMap<String, OntologyPack>,
88}
89
90pub 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 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 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
218fn 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
256pub 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 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 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 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 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}