1use serde::Serialize;
18use termivar_core::{
19 ApiEvidencePredicate, ApiKnowledgePredicate, ApiResponseFormat, ApiSurfaceKind,
20 ApiVisibilityBoundaryKind, ApiVisibilityDimension, ApiVisibilityPairKind, ApiVisibilityResult,
21 ConceptId, EvidenceValue, HttpEvidencePredicate, HypothesisState, HypothesisStrength, Ontology,
22 OntologyAxiom, OntologyConcept, OntologyError, PredicateDescriptor, Probability,
23};
24use thiserror::Error;
25
26use crate::{
27 knowledge::KnowledgeBase,
28 rules::{
29 EvidenceAggregation, EvidenceCalibration, EvidenceSelector, Expression,
30 HypothesisConclusion, KnowledgeLayer, ReasoningRule, RuleEngine, RuleEngineError,
31 RuleWrite,
32 },
33};
34
35pub const STANDARD_API_CONCEPT_COUNT: usize = 9;
37
38pub const STANDARD_API_AXIOM_COUNT: usize = 6;
40
41pub const STANDARD_API_RULE_COUNT: usize = 7;
43
44#[derive(Debug, Error)]
46#[non_exhaustive]
47pub enum StandardApiReasoningError {
48 #[error(transparent)]
50 Ontology(#[from] OntologyError),
51
52 #[error(transparent)]
54 Rules(#[from] RuleEngineError),
55
56 #[error("standard API reasoning profile does not support API surface `{surface}`")]
59 UnsupportedApiSurface {
60 surface: String,
62 },
63
64 #[error("standard API reasoning profile does not support visibility pair `{pair}`")]
67 UnsupportedVisibilityPair {
68 pair: String,
70 },
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
75pub struct StandardApiInstallReport {
76 concepts_inserted: usize,
77 axioms_inserted: usize,
78 rules_inserted: usize,
79}
80
81impl StandardApiInstallReport {
82 pub const fn concepts_inserted(self) -> usize {
84 self.concepts_inserted
85 }
86
87 pub const fn axioms_inserted(self) -> usize {
89 self.axioms_inserted
90 }
91
92 pub const fn rules_inserted(self) -> usize {
94 self.rules_inserted
95 }
96}
97
98#[derive(Debug, Clone)]
119pub struct StandardApiReasoning {
120 concepts: Vec<OntologyConcept>,
121 axioms: Vec<OntologyAxiom>,
122 rules: Vec<ReasoningRule>,
123}
124
125impl StandardApiReasoning {
126 pub fn new() -> Result<Self, StandardApiReasoningError> {
128 let concepts = standard_concepts()?;
129 let axioms = standard_axioms()?;
130 let rules = standard_rules()?;
131
132 let mut validation = Ontology::new();
133 for concept in &concepts {
134 validation.add_concept(concept.clone())?;
135 }
136 for axiom in &axioms {
137 validation.add_axiom(axiom.clone())?;
138 }
139
140 debug_assert_eq!(concepts.len(), STANDARD_API_CONCEPT_COUNT);
141 debug_assert_eq!(axioms.len(), STANDARD_API_AXIOM_COUNT);
142 debug_assert_eq!(rules.len(), STANDARD_API_RULE_COUNT);
143 Ok(Self {
144 concepts,
145 axioms,
146 rules,
147 })
148 }
149
150 pub fn install(
156 &self,
157 knowledge: &KnowledgeBase,
158 engine: &mut RuleEngine,
159 ) -> Result<StandardApiInstallReport, StandardApiReasoningError> {
160 let mut prospective_engine = engine.clone();
161 let mut rules_inserted = 0;
162 for rule in &self.rules {
163 rules_inserted += usize::from(matches!(
164 prospective_engine.register(rule.clone())?,
165 RuleWrite::Inserted
166 ));
167 }
168
169 let (concepts_inserted, axioms_inserted) =
170 knowledge.install_ontology_definitions(&self.concepts, &self.axioms)?;
171 *engine = prospective_engine;
172
173 Ok(StandardApiInstallReport {
174 concepts_inserted,
175 axioms_inserted,
176 rules_inserted,
177 })
178 }
179
180 pub fn concepts(&self) -> &[OntologyConcept] {
182 &self.concepts
183 }
184
185 pub fn axioms(&self) -> &[OntologyAxiom] {
187 &self.axioms
188 }
189
190 pub fn rules(&self) -> &[ReasoningRule] {
192 &self.rules
193 }
194}
195
196fn standard_concepts() -> Result<Vec<OntologyConcept>, OntologyError> {
197 [
198 ("application-interface", "Application interface"),
199 ("api-interface", "API interface"),
200 ("api-response-format", "API response format"),
201 ("json", "JSON response format"),
202 ("json-http-api", "JSON HTTP API"),
203 ("graphql-api", "GraphQL API"),
204 ("visibility-boundary", "Visibility boundary"),
205 ("ui-api-visibility-boundary", "UI/API visibility boundary"),
206 (
207 "authorization-context-visibility-boundary",
208 "Authorization-context visibility boundary",
209 ),
210 ]
211 .into_iter()
212 .map(|(id, label)| OntologyConcept::new(ConceptId::new(id)?, label))
213 .collect()
214}
215
216fn standard_axioms() -> Result<Vec<OntologyAxiom>, OntologyError> {
217 let is_a = Ontology::relation_id(Ontology::IS_A)?;
218 [
219 ("api-interface", "application-interface"),
220 ("json", "api-response-format"),
221 ("json-http-api", "api-interface"),
222 ("graphql-api", "api-interface"),
223 ("ui-api-visibility-boundary", "visibility-boundary"),
224 (
225 "authorization-context-visibility-boundary",
226 "visibility-boundary",
227 ),
228 ]
229 .into_iter()
230 .map(|(subject, object)| {
231 Ok(OntologyAxiom::new(
232 ConceptId::new(subject)?,
233 is_a.clone(),
234 ConceptId::new(object)?,
235 ))
236 })
237 .collect()
238}
239
240fn standard_rules() -> Result<Vec<ReasoningRule>, StandardApiReasoningError> {
241 Ok(vec![
242 json_response_rule()?,
243 graphql_media_type_rule()?,
244 graphql_route_rule()?,
245 comparison_surface_rule(ApiSurfaceKind::JsonHttp)?,
246 comparison_surface_rule(ApiSurfaceKind::GraphQl)?,
247 visibility_boundary_rule(ApiVisibilityPairKind::UiApi)?,
248 visibility_boundary_rule(ApiVisibilityPairKind::AuthorizationContext)?,
249 ])
250}
251
252fn json_response_rule() -> Result<ReasoningRule, RuleEngineError> {
253 let json_compatible =
254 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge();
255 ReasoningRule::new(
256 "api.response.json.media-type",
257 Expression::equals(
258 KnowledgeLayer::Evidence,
259 json_compatible.clone(),
260 EvidenceValue::Boolean(true),
261 ),
262 HypothesisConclusion::new(
263 ApiKnowledgePredicate::RESPONSE_FORMAT.into(),
264 ApiResponseFormat::Json.into(),
265 probability(20)?,
266 HypothesisStrength::Weak,
267 HypothesisState::Supported,
268 vec![calibration(
269 EvidenceSelector::equals(json_compatible, EvidenceValue::Boolean(true)),
270 95,
271 5,
272 "Validated media type uses JSON or a +json structured suffix",
273 )?],
274 )?,
275 )
276}
277
278fn graphql_media_type_rule() -> Result<ReasoningRule, RuleEngineError> {
279 let media_type = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
280 let graphql_media_type = EvidenceValue::Text("application/graphql-response+json".to_owned());
281 ReasoningRule::new(
282 "api.surface.graphql.response-media-type",
283 Expression::equals(
284 KnowledgeLayer::Evidence,
285 media_type.clone(),
286 graphql_media_type.clone(),
287 ),
288 HypothesisConclusion::new(
289 ApiKnowledgePredicate::SURFACE_KIND.into(),
290 ApiSurfaceKind::GraphQl.into(),
291 probability(5)?,
292 HypothesisStrength::Strong,
293 HypothesisState::Supported,
294 vec![calibration(
295 EvidenceSelector::equals(media_type, graphql_media_type),
296 99,
297 1,
298 "Content-Type uses the GraphQL response media type",
299 )?],
300 )?,
301 )
302}
303
304fn graphql_route_rule() -> Result<ReasoningRule, RuleEngineError> {
305 let path_segment = HttpEvidencePredicate::REQUEST_PATH_SEGMENT.into_knowledge();
306 let graphql = EvidenceValue::Text("graphql".to_owned());
307 ReasoningRule::new(
308 "api.surface.graphql.route",
309 Expression::equals(
310 KnowledgeLayer::Evidence,
311 path_segment.clone(),
312 graphql.clone(),
313 ),
314 HypothesisConclusion::new(
315 ApiKnowledgePredicate::SURFACE_KIND.into(),
316 ApiSurfaceKind::GraphQl.into(),
317 probability(5)?,
318 HypothesisStrength::Weak,
319 HypothesisState::Supported,
320 vec![calibration(
321 EvidenceSelector::equals(path_segment, graphql),
322 75,
323 25,
324 "Request path uses a conventional GraphQL route token",
325 )?],
326 )?,
327 )
328}
329
330fn comparison_surface_rule(
331 surface: ApiSurfaceKind,
332) -> Result<ReasoningRule, StandardApiReasoningError> {
333 let predicates = comparison_predicates(surface);
334 ReasoningRule::new(
335 comparison_surface_rule_id(surface.as_str())?,
336 any_comparison_dimension(&predicates)?,
337 HypothesisConclusion::new(
338 ApiKnowledgePredicate::SURFACE_KIND.into(),
339 surface.into(),
340 probability(20)?,
341 HypothesisStrength::Strong,
342 HypothesisState::Supported,
343 comparison_calibrations(
344 &predicates,
345 99,
346 1,
347 "A host-paired comparison explicitly declares this API surface",
348 )?,
349 )?,
350 )
351 .map_err(Into::into)
352}
353
354fn comparison_surface_rule_id(surface: &str) -> Result<&'static str, StandardApiReasoningError> {
355 match surface {
356 "json-http-api" => Ok("api.surface.json.paired-comparison"),
357 "graphql-api" => Ok("api.surface.graphql.paired-comparison"),
358 _ => Err(StandardApiReasoningError::UnsupportedApiSurface {
359 surface: surface.to_owned(),
360 }),
361 }
362}
363
364fn visibility_boundary_rule(
365 pair: ApiVisibilityPairKind,
366) -> Result<ReasoningRule, StandardApiReasoningError> {
367 let predicates = [
368 ApiEvidencePredicate::visibility(
369 ApiSurfaceKind::JsonHttp,
370 pair,
371 ApiVisibilityResult::Different,
372 ),
373 ApiEvidencePredicate::visibility(
374 ApiSurfaceKind::GraphQl,
375 pair,
376 ApiVisibilityResult::Different,
377 ),
378 ];
379 let (id, boundary, likelihood_if_true, rationale) =
380 visibility_boundary_rule_spec(pair.as_str())?;
381 ReasoningRule::new(
382 id,
383 any_comparison_dimension(&predicates)?,
384 HypothesisConclusion::new(
385 ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into(),
386 boundary.into(),
387 probability(10)?,
388 HypothesisStrength::Weak,
389 HypothesisState::Supported,
390 comparison_calibrations(
391 &predicates,
392 likelihood_if_true,
393 100 - likelihood_if_true,
394 rationale,
395 )?,
396 )?,
397 )
398 .map_err(Into::into)
399}
400
401fn visibility_boundary_rule_spec(
402 pair: &str,
403) -> Result<(&'static str, ApiVisibilityBoundaryKind, u8, &'static str), StandardApiReasoningError>
404{
405 match pair {
406 "ui-api" => Ok((
407 "api.visibility.ui-api.paired-difference",
408 ApiVisibilityBoundaryKind::UiApi,
409 98,
410 "One atomic same-resource UI/API comparison observed a visibility difference",
411 )),
412 "authorization-context" => Ok((
413 "api.visibility.authorization-context.paired-difference",
414 ApiVisibilityBoundaryKind::AuthorizationContext,
415 99,
416 "One atomic same-resource authorization-context comparison observed a visibility difference",
417 )),
418 _ => Err(StandardApiReasoningError::UnsupportedVisibilityPair {
419 pair: pair.to_owned(),
420 }),
421 }
422}
423
424fn comparison_predicates(surface: ApiSurfaceKind) -> Vec<PredicateDescriptor> {
425 [
426 ApiVisibilityPairKind::UiApi,
427 ApiVisibilityPairKind::AuthorizationContext,
428 ]
429 .into_iter()
430 .flat_map(|pair| {
431 [
432 ApiVisibilityResult::Different,
433 ApiVisibilityResult::Equivalent,
434 ]
435 .iter()
436 .map(move |result| ApiEvidencePredicate::visibility(surface, pair, *result))
437 })
438 .collect()
439}
440
441fn any_comparison_dimension(
442 predicates: &[PredicateDescriptor],
443) -> Result<Expression, RuleEngineError> {
444 Expression::any(
445 predicates
446 .iter()
447 .flat_map(|predicate| {
448 ApiVisibilityDimension::all().into_iter().map(|dimension| {
449 Expression::equals(
450 KnowledgeLayer::Evidence,
451 predicate.into_knowledge(),
452 dimension.into(),
453 )
454 })
455 })
456 .collect(),
457 )
458}
459
460fn comparison_calibrations(
461 predicates: &[PredicateDescriptor],
462 likelihood_if_true: u8,
463 likelihood_if_false: u8,
464 rationale: &str,
465) -> Result<Vec<EvidenceCalibration>, RuleEngineError> {
466 predicates
467 .iter()
468 .flat_map(|predicate| {
469 ApiVisibilityDimension::all().into_iter().map(|dimension| {
470 calibration(
471 EvidenceSelector::equals(predicate.into_knowledge(), dimension.into()),
472 likelihood_if_true,
473 likelihood_if_false,
474 rationale,
475 )
476 })
477 })
478 .collect()
479}
480
481fn calibration(
482 selector: EvidenceSelector,
483 likelihood_if_true: u8,
484 likelihood_if_false: u8,
485 rationale: &str,
486) -> Result<EvidenceCalibration, RuleEngineError> {
487 Ok(EvidenceCalibration::new(
488 selector,
489 probability(likelihood_if_true)?,
490 probability(likelihood_if_false)?,
491 rationale,
492 )?
493 .with_aggregation(EvidenceAggregation::max_contributions(1)?))
494}
495
496fn probability(percent: u8) -> Result<Probability, RuleEngineError> {
497 Ok(Probability::from_percent(percent)?)
498}
499
500#[cfg(test)]
501mod tests {
502 use termivar_core::{
503 ApiVisibilityComparison, ApiVisibilityDimension, ConfidenceScore, EntityId, Evidence,
504 EvidenceKind, EvidenceSource, Hypothesis, KnowledgePredicate, OntologyConcept,
505 OntologyWrite,
506 };
507
508 use super::*;
509 use crate::{KnowledgeWrite, StandardWebReasoning};
510
511 fn endpoint() -> EntityId {
512 EntityId::new("endpoint:https://example.test/graphql").unwrap()
513 }
514
515 fn evidence(predicate: PredicateDescriptor, value: EvidenceValue) -> Evidence {
516 Evidence::new(
517 endpoint(),
518 EvidenceKind::Http,
519 predicate.into_knowledge(),
520 value,
521 EvidenceSource::new("http.evidence", "test-observation").unwrap(),
522 ConfidenceScore::MAX,
523 )
524 }
525
526 fn text_evidence(predicate: PredicateDescriptor, value: &str) -> Evidence {
527 evidence(predicate, EvidenceValue::Text(value.to_owned()))
528 }
529
530 fn hypotheses(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Hypothesis> {
531 knowledge
532 .snapshot_for_subject(subject)
533 .hypotheses()
534 .to_vec()
535 }
536
537 fn hypothesis(
538 hypotheses: &[Hypothesis],
539 predicate: PredicateDescriptor,
540 value: EvidenceValue,
541 ) -> Option<&Hypothesis> {
542 hypotheses.iter().find(|hypothesis| {
543 hypothesis.predicate() == &predicate.into_knowledge() && hypothesis.value() == &value
544 })
545 }
546
547 fn apply(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Hypothesis> {
548 let mut engine = RuleEngine::new();
549 StandardApiReasoning::new()
550 .unwrap()
551 .install(knowledge, &mut engine)
552 .unwrap();
553 engine.apply(knowledge, subject).unwrap();
554 hypotheses(knowledge, subject)
555 }
556
557 #[test]
558 fn profile_installs_idempotently_and_composes_with_web_profile() {
559 for api_first in [true, false] {
560 let knowledge = KnowledgeBase::new();
561 let mut engine = RuleEngine::new();
562 let api = StandardApiReasoning::new().unwrap();
563 let web = StandardWebReasoning::new().unwrap();
564
565 if api_first {
566 api.install(&knowledge, &mut engine).unwrap();
567 web.install(&knowledge, &mut engine).unwrap();
568 } else {
569 web.install(&knowledge, &mut engine).unwrap();
570 api.install(&knowledge, &mut engine).unwrap();
571 }
572 let repeated = api.install(&knowledge, &mut engine).unwrap();
573
574 assert_eq!(repeated, StandardApiInstallReport::default());
575 assert_eq!(
576 engine.len(),
577 STANDARD_API_RULE_COUNT + crate::STANDARD_WEB_RULE_COUNT
578 );
579 assert!(knowledge
580 .ontology_is_a(
581 &ConceptId::new("graphql-api").unwrap(),
582 &ConceptId::new("application-interface").unwrap(),
583 )
584 .unwrap());
585 }
586 }
587
588 #[test]
589 fn standard_api_profile_rule_manifest_is_exact() {
590 let profile = StandardApiReasoning::new().unwrap();
591 let rule_ids: Vec<_> = profile.rules().iter().map(ReasoningRule::id).collect();
592
593 assert_eq!(
594 rule_ids,
595 [
596 "api.response.json.media-type",
597 "api.surface.graphql.response-media-type",
598 "api.surface.graphql.route",
599 "api.surface.json.paired-comparison",
600 "api.surface.graphql.paired-comparison",
601 "api.visibility.ui-api.paired-difference",
602 "api.visibility.authorization-context.paired-difference",
603 ]
604 );
605 assert_eq!(rule_ids.len(), STANDARD_API_RULE_COUNT);
606 }
607
608 #[test]
609 fn every_standard_api_surface_has_one_stable_rule() {
610 for (surface, expected_id) in [
611 (
612 ApiSurfaceKind::JsonHttp,
613 "api.surface.json.paired-comparison",
614 ),
615 (
616 ApiSurfaceKind::GraphQl,
617 "api.surface.graphql.paired-comparison",
618 ),
619 ] {
620 assert_eq!(comparison_surface_rule(surface).unwrap().id(), expected_id);
621 assert_eq!(
622 comparison_surface_rule_id(surface.as_str()).unwrap(),
623 expected_id
624 );
625 }
626 }
627
628 #[test]
629 fn every_standard_visibility_pair_has_one_stable_boundary_rule() {
630 for (pair, expected_id) in [
631 (
632 ApiVisibilityPairKind::UiApi,
633 "api.visibility.ui-api.paired-difference",
634 ),
635 (
636 ApiVisibilityPairKind::AuthorizationContext,
637 "api.visibility.authorization-context.paired-difference",
638 ),
639 ] {
640 assert_eq!(visibility_boundary_rule(pair).unwrap().id(), expected_id);
641 assert_eq!(
642 visibility_boundary_rule_spec(pair.as_str()).unwrap().0,
643 expected_id
644 );
645 }
646 }
647
648 #[test]
649 fn unknown_surface_key_fails_closed_with_typed_error() {
650 assert!(matches!(
651 comparison_surface_rule_id("future-api-surface"),
652 Err(StandardApiReasoningError::UnsupportedApiSurface { surface })
653 if surface == "future-api-surface"
654 ));
655 }
656
657 #[test]
658 fn unknown_visibility_pair_key_fails_closed_with_typed_error() {
659 assert!(matches!(
660 visibility_boundary_rule_spec("future-visibility-pair"),
661 Err(StandardApiReasoningError::UnsupportedVisibilityPair { pair })
662 if pair == "future-visibility-pair"
663 ));
664 }
665
666 #[test]
667 fn generic_json_never_implies_graphql() {
668 let knowledge = KnowledgeBase::new();
669 knowledge
670 .insert_evidence(evidence(
671 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
672 EvidenceValue::Boolean(true),
673 ))
674 .unwrap();
675
676 let hypotheses = apply(&knowledge, &endpoint());
677
678 assert!(hypothesis(
679 &hypotheses,
680 ApiKnowledgePredicate::RESPONSE_FORMAT,
681 ApiResponseFormat::Json.into(),
682 )
683 .is_some());
684 assert!(hypothesis(
685 &hypotheses,
686 ApiKnowledgePredicate::SURFACE_KIND,
687 ApiSurfaceKind::GraphQl.into(),
688 )
689 .is_none());
690 }
691
692 #[test]
693 fn graphql_media_type_is_strong_and_route_signal_is_weak() {
694 let media_knowledge = KnowledgeBase::new();
695 media_knowledge
696 .insert_evidence(text_evidence(
697 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
698 "application/graphql-response+json",
699 ))
700 .unwrap();
701 let media = apply(&media_knowledge, &endpoint());
702 assert_eq!(
703 hypothesis(
704 &media,
705 ApiKnowledgePredicate::SURFACE_KIND,
706 ApiSurfaceKind::GraphQl.into(),
707 )
708 .unwrap()
709 .strength(),
710 HypothesisStrength::Strong
711 );
712
713 let route_knowledge = KnowledgeBase::new();
714 route_knowledge
715 .insert_evidence(text_evidence(
716 HttpEvidencePredicate::REQUEST_PATH_SEGMENT,
717 "graphql",
718 ))
719 .unwrap();
720 let route = apply(&route_knowledge, &endpoint());
721 assert_eq!(
722 hypothesis(
723 &route,
724 ApiKnowledgePredicate::SURFACE_KIND,
725 ApiSurfaceKind::GraphQl.into(),
726 )
727 .unwrap()
728 .strength(),
729 HypothesisStrength::Weak
730 );
731 }
732
733 #[test]
734 fn raw_substrings_and_malformed_comparison_values_are_ignored() {
735 let knowledge = KnowledgeBase::new();
736 knowledge
737 .insert_evidence_batch(vec![
738 text_evidence(
739 HttpEvidencePredicate::HEADER_CONTENT_TYPE,
740 "application/jsonp; note=application/graphql-response+json",
741 ),
742 text_evidence(
743 HttpEvidencePredicate::REQUEST_URL,
744 "https://example.test/?next=/graphql",
745 ),
746 evidence(
747 ApiEvidencePredicate::JSON_UI_API_DIFFERENCE,
748 EvidenceValue::Boolean(true),
749 ),
750 ])
751 .unwrap();
752
753 assert!(apply(&knowledge, &endpoint()).is_empty());
754 }
755
756 fn comparison(
757 pair: ApiVisibilityPairKind,
758 result: ApiVisibilityResult,
759 candidate: &str,
760 ) -> ApiVisibilityComparison {
761 ApiVisibilityComparison::new(
762 "comparison-1",
763 ApiSurfaceKind::JsonHttp,
764 pair,
765 result,
766 ApiVisibilityDimension::Fields,
767 "baseline",
768 candidate,
769 "account-record",
770 )
771 .unwrap()
772 }
773
774 #[test]
775 fn one_atomic_difference_produces_only_the_matching_review_boundary() {
776 let comparison = comparison(
777 ApiVisibilityPairKind::AuthorizationContext,
778 ApiVisibilityResult::Different,
779 "member",
780 );
781 let observation = comparison
782 .to_observation("api.visibility", ConfidenceScore::MAX)
783 .unwrap();
784 let evidence_id = observation.evidence().id().clone();
785 let subject = observation.evidence().subject().clone();
786 let resource_scope = observation.resource_scope().clone();
787 let (evidence, relation) = observation.into_parts();
788 let knowledge = KnowledgeBase::new();
789 assert_eq!(
790 knowledge
791 .insert_evidence_with_relation(evidence, relation)
792 .unwrap(),
793 (KnowledgeWrite::Inserted, KnowledgeWrite::Inserted)
794 );
795
796 let hypotheses = apply(&knowledge, &subject);
797 let boundary = hypothesis(
798 &hypotheses,
799 ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
800 ApiVisibilityBoundaryKind::AuthorizationContext.into(),
801 )
802 .unwrap();
803
804 assert_eq!(boundary.state(), HypothesisState::Supported);
805 assert_eq!(boundary.strength(), HypothesisStrength::Weak);
806 assert_eq!(boundary.belief().evidence().len(), 1);
807 assert_eq!(boundary.belief().evidence()[0].evidence_id(), &evidence_id);
808 assert!(hypothesis(
809 &hypotheses,
810 ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
811 ApiVisibilityBoundaryKind::UiApi.into(),
812 )
813 .is_none());
814 assert!(knowledge.snapshot_for_subject(&subject).facts().is_empty());
815 assert_eq!(knowledge.relations_from(&subject)[0].to(), &resource_scope);
816 }
817
818 #[test]
819 fn equivalent_comparison_never_creates_a_negative_or_boundary_claim() {
820 let comparison = comparison(
821 ApiVisibilityPairKind::UiApi,
822 ApiVisibilityResult::Equivalent,
823 "api",
824 );
825 let evidence = comparison
826 .to_evidence("api.visibility", ConfidenceScore::MAX)
827 .unwrap();
828 let subject = evidence.subject().clone();
829 let knowledge = KnowledgeBase::new();
830 knowledge.insert_evidence(evidence).unwrap();
831
832 let hypotheses = apply(&knowledge, &subject);
833
834 assert!(hypotheses.iter().all(|hypothesis| !matches!(
835 hypothesis.state(),
836 HypothesisState::Confirmed | HypothesisState::Rejected
837 )));
838 assert!(hypotheses.iter().all(|hypothesis| hypothesis.predicate()
839 != &ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge()));
840 assert!(hypothesis(
841 &hypotheses,
842 ApiKnowledgePredicate::SURFACE_KIND,
843 ApiSurfaceKind::JsonHttp.into(),
844 )
845 .is_some());
846 }
847
848 #[test]
849 fn comparison_subjects_isolate_principal_contexts() {
850 let member = comparison(
851 ApiVisibilityPairKind::AuthorizationContext,
852 ApiVisibilityResult::Different,
853 "member",
854 )
855 .to_evidence("api.visibility", ConfidenceScore::MAX)
856 .unwrap();
857 let admin = comparison(
858 ApiVisibilityPairKind::AuthorizationContext,
859 ApiVisibilityResult::Equivalent,
860 "admin",
861 )
862 .to_evidence("api.visibility", ConfidenceScore::MAX)
863 .unwrap();
864 assert_ne!(member.subject(), admin.subject());
865 let member_subject = member.subject().clone();
866 let admin_subject = admin.subject().clone();
867 let knowledge = KnowledgeBase::new();
868 knowledge
869 .insert_evidence_batch(vec![member, admin])
870 .unwrap();
871
872 let member_hypotheses = apply(&knowledge, &member_subject);
873 let admin_hypotheses = apply(&knowledge, &admin_subject);
874
875 assert!(hypothesis(
876 &member_hypotheses,
877 ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
878 ApiVisibilityBoundaryKind::AuthorizationContext.into(),
879 )
880 .is_some());
881 assert!(hypothesis(
882 &admin_hypotheses,
883 ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
884 ApiVisibilityBoundaryKind::AuthorizationContext.into(),
885 )
886 .is_none());
887 }
888
889 #[test]
890 fn rule_conflict_preflight_leaves_ontology_unchanged() {
891 let knowledge = KnowledgeBase::new();
892 let mut engine = RuleEngine::new();
893 let profile = StandardApiReasoning::new().unwrap();
894 let conflict_source = HttpEvidencePredicate::HEADER_SERVER.into_knowledge();
895 engine
896 .register(
897 ReasoningRule::new(
898 profile.rules()[0].id(),
899 Expression::exists(KnowledgeLayer::Evidence, conflict_source.clone()),
900 HypothesisConclusion::new(
901 KnowledgePredicate::new("conflict", "claim").unwrap(),
902 EvidenceValue::Boolean(true),
903 probability(50).unwrap(),
904 HypothesisStrength::Weak,
905 HypothesisState::Supported,
906 vec![calibration(
907 EvidenceSelector::exists(conflict_source),
908 60,
909 40,
910 "test conflict",
911 )
912 .unwrap()],
913 )
914 .unwrap(),
915 )
916 .unwrap(),
917 )
918 .unwrap();
919 let rules_before = engine.len();
920
921 assert!(profile.install(&knowledge, &mut engine).is_err());
922 assert_eq!(engine.len(), rules_before);
923 assert_eq!(knowledge.ontology_snapshot().stats().concepts, 0);
924 }
925
926 #[test]
927 fn ontology_conflict_leaves_rule_registry_unchanged() {
928 let knowledge = KnowledgeBase::new();
929 assert_eq!(
930 knowledge
931 .register_concept(
932 OntologyConcept::new(
933 ConceptId::new("api-interface").unwrap(),
934 "Conflicting API label",
935 )
936 .unwrap(),
937 )
938 .unwrap(),
939 OntologyWrite::Inserted
940 );
941 let mut engine = RuleEngine::new();
942
943 assert!(StandardApiReasoning::new()
944 .unwrap()
945 .install(&knowledge, &mut engine)
946 .is_err());
947 assert!(engine.is_empty());
948 }
949}