1use serde::{Deserialize, Deserializer, Serialize};
2use std::num::NonZeroU32;
3use termivar_core::{
4 EvidenceValue, HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
5};
6
7use crate::rules::{
8 expression::{
9 evidence_value_texts, is_false, text_contains, text_list_contains_exact, Expression,
10 RequiredNullable,
11 },
12 non_empty, RuleEngineError,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct EvidenceSelector {
24 predicate: KnowledgePredicate,
25 value: Option<EvidenceValue>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 text_contains_ascii_case_insensitive: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 text_list_contains_exact: Option<String>,
30 #[serde(default, skip_serializing_if = "is_false")]
31 matcher_policy_guard: bool,
32}
33
34impl EvidenceSelector {
35 pub fn equals(predicate: KnowledgePredicate, value: EvidenceValue) -> Self {
37 Self {
38 predicate,
39 value: Some(value),
40 text_contains_ascii_case_insensitive: None,
41 text_list_contains_exact: None,
42 matcher_policy_guard: true,
43 }
44 }
45
46 pub fn exists(predicate: KnowledgePredicate) -> Self {
48 Self {
49 predicate,
50 value: None,
51 text_contains_ascii_case_insensitive: None,
52 text_list_contains_exact: None,
53 matcher_policy_guard: false,
54 }
55 }
56
57 pub fn text_contains_ascii_case_insensitive(
59 predicate: KnowledgePredicate,
60 needle: impl Into<String>,
61 ) -> Result<Self, RuleEngineError> {
62 Ok(Self {
63 predicate,
64 value: None,
65 text_contains_ascii_case_insensitive: Some(non_empty(
66 needle,
67 "evidence-selector text needle",
68 )?),
69 text_list_contains_exact: None,
70 matcher_policy_guard: true,
71 })
72 }
73
74 pub fn text_list_contains_exact(
80 predicate: KnowledgePredicate,
81 value: impl Into<String>,
82 ) -> Result<Self, RuleEngineError> {
83 Ok(Self {
84 predicate,
85 value: None,
86 text_contains_ascii_case_insensitive: None,
87 text_list_contains_exact: Some(non_empty(
88 value,
89 "evidence-selector text-list exact value",
90 )?),
91 matcher_policy_guard: true,
92 })
93 }
94
95 pub fn predicate(&self) -> &KnowledgePredicate {
97 &self.predicate
98 }
99
100 pub fn value(&self) -> Option<&EvidenceValue> {
102 self.value.as_ref()
103 }
104
105 pub fn text_needle(&self) -> Option<&str> {
107 self.text_contains_ascii_case_insensitive.as_deref()
108 }
109
110 pub fn text_list_exact_value(&self) -> Option<&str> {
112 self.text_list_contains_exact.as_deref()
113 }
114
115 pub(super) fn matches(&self, evidence: &termivar_core::Evidence) -> bool {
116 evidence.predicate() == &self.predicate
117 && self
118 .value
119 .as_ref()
120 .is_none_or(|expected| evidence.value() == expected)
121 && self
122 .text_contains_ascii_case_insensitive
123 .as_ref()
124 .is_none_or(|needle| {
125 evidence_value_texts(evidence.value())
126 .any(|text| text_contains(text, needle, true))
127 })
128 && self
129 .text_list_contains_exact
130 .as_ref()
131 .is_none_or(|value| text_list_contains_exact(evidence.value(), value))
132 }
133}
134
135impl<'de> Deserialize<'de> for EvidenceSelector {
136 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137 where
138 D: Deserializer<'de>,
139 {
140 #[derive(Deserialize)]
141 #[serde(deny_unknown_fields)]
142 struct WireSelector {
143 predicate: KnowledgePredicate,
144 #[serde(default)]
145 value: RequiredNullable<EvidenceValue>,
146 #[serde(default)]
147 text_contains_ascii_case_insensitive: Option<String>,
148 #[serde(default)]
149 text_list_contains_exact: Option<String>,
150 #[serde(default)]
151 matcher_policy_guard: Option<bool>,
152 }
153
154 let wire = WireSelector::deserialize(deserializer)?;
155 if !wire.value.is_present() {
156 return Err(serde::de::Error::custom(
157 "evidence selector value field must be present; use null for predicate existence",
158 ));
159 }
160 let value = wire.value.into_inner();
161 let matchers = usize::from(value.is_some())
162 + usize::from(wire.text_contains_ascii_case_insensitive.is_some())
163 + usize::from(wire.text_list_contains_exact.is_some());
164 if matchers > 1 {
165 return Err(serde::de::Error::custom(
166 "evidence selector cannot combine exact, text, and text-list matching",
167 ));
168 }
169 if wire
170 .matcher_policy_guard
171 .is_some_and(|guard| !guard || matchers != 1)
172 {
173 return Err(serde::de::Error::custom(
174 "evidence selector matcher compatibility guard is inconsistent",
175 ));
176 }
177 if wire
178 .text_contains_ascii_case_insensitive
179 .as_ref()
180 .is_some_and(|needle| needle.trim().is_empty())
181 {
182 return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
183 field: "evidence-selector text needle",
184 }));
185 }
186 if wire
187 .text_list_contains_exact
188 .as_ref()
189 .is_some_and(|value| value.trim().is_empty())
190 {
191 return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
192 field: "evidence-selector text-list exact value",
193 }));
194 }
195 Ok(Self {
196 predicate: wire.predicate,
197 value,
198 text_contains_ascii_case_insensitive: wire.text_contains_ascii_case_insensitive,
199 text_list_contains_exact: wire.text_list_contains_exact,
200 matcher_policy_guard: matchers == 1,
201 })
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
211#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
212#[non_exhaustive]
213pub enum EvidenceAggregation {
214 #[default]
216 Independent,
217 MaxContributions {
222 limit: NonZeroU32,
224 },
225}
226
227impl EvidenceAggregation {
228 pub fn max_contributions(limit: u32) -> Result<Self, RuleEngineError> {
230 Ok(Self::MaxContributions {
231 limit: NonZeroU32::new(limit).ok_or(RuleEngineError::InvalidAggregationLimit)?,
232 })
233 }
234
235 pub(super) fn limit(self) -> Option<usize> {
236 match self {
237 Self::Independent => None,
238 Self::MaxContributions { limit } => {
239 Some(usize::try_from(limit.get()).unwrap_or(usize::MAX))
240 },
241 }
242 }
243
244 fn is_independent(&self) -> bool {
245 matches!(self, Self::Independent)
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
257pub struct EvidenceCalibration {
258 pub(super) selector: EvidenceSelector,
259 pub(super) likelihood_if_true: Probability,
260 pub(super) likelihood_if_false: Probability,
261 pub(super) rationale: String,
262 #[serde(default, skip_serializing_if = "EvidenceAggregation::is_independent")]
263 pub(super) aggregation: EvidenceAggregation,
264 #[serde(default, skip_serializing_if = "is_false")]
265 aggregation_policy_guard: bool,
266}
267
268impl EvidenceCalibration {
269 pub fn new(
271 selector: EvidenceSelector,
272 likelihood_if_true: Probability,
273 likelihood_if_false: Probability,
274 rationale: impl Into<String>,
275 ) -> Result<Self, RuleEngineError> {
276 Ok(Self {
277 selector,
278 likelihood_if_true,
279 likelihood_if_false,
280 rationale: non_empty(rationale, "evidence calibration rationale")?,
281 aggregation: EvidenceAggregation::Independent,
282 aggregation_policy_guard: false,
283 })
284 }
285
286 pub fn with_aggregation(mut self, aggregation: EvidenceAggregation) -> Self {
288 self.aggregation_policy_guard = !aggregation.is_independent();
289 self.aggregation = aggregation;
290 self
291 }
292
293 pub fn selector(&self) -> &EvidenceSelector {
295 &self.selector
296 }
297
298 pub fn likelihood_if_true(&self) -> Probability {
300 self.likelihood_if_true
301 }
302
303 pub fn likelihood_if_false(&self) -> Probability {
305 self.likelihood_if_false
306 }
307
308 pub fn rationale(&self) -> &str {
310 &self.rationale
311 }
312
313 pub const fn aggregation(&self) -> EvidenceAggregation {
315 self.aggregation
316 }
317}
318
319impl<'de> Deserialize<'de> for EvidenceCalibration {
320 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321 where
322 D: Deserializer<'de>,
323 {
324 #[derive(Deserialize)]
325 #[serde(deny_unknown_fields)]
326 struct WireCalibration {
327 selector: EvidenceSelector,
328 likelihood_if_true: Probability,
329 likelihood_if_false: Probability,
330 rationale: String,
331 #[serde(default)]
332 aggregation: EvidenceAggregation,
333 #[serde(default)]
334 aggregation_policy_guard: Option<bool>,
335 }
336
337 let wire = WireCalibration::deserialize(deserializer)?;
338 let bounded = !wire.aggregation.is_independent();
339 if wire
340 .aggregation_policy_guard
341 .is_some_and(|guard| !guard || !bounded)
342 {
343 return Err(serde::de::Error::custom(
344 "evidence aggregation compatibility guard is inconsistent",
345 ));
346 }
347 Self::new(
348 wire.selector,
349 wire.likelihood_if_true,
350 wire.likelihood_if_false,
351 wire.rationale,
352 )
353 .map(|calibration| calibration.with_aggregation(wire.aggregation))
354 .map_err(serde::de::Error::custom)
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
360pub struct HypothesisConclusion {
361 pub(super) predicate: KnowledgePredicate,
362 pub(super) value: EvidenceValue,
363 pub(super) prior: Probability,
364 pub(super) strength: HypothesisStrength,
365 pub(super) state: HypothesisState,
366 pub(super) calibrations: Vec<EvidenceCalibration>,
367}
368
369impl HypothesisConclusion {
370 pub fn new(
372 predicate: KnowledgePredicate,
373 value: EvidenceValue,
374 prior: Probability,
375 strength: HypothesisStrength,
376 state: HypothesisState,
377 calibrations: Vec<EvidenceCalibration>,
378 ) -> Result<Self, RuleEngineError> {
379 if calibrations.is_empty() {
380 return Err(RuleEngineError::EmptyCalibrations);
381 }
382 if matches!(
383 state,
384 HypothesisState::Confirmed | HypothesisState::Rejected
385 ) {
386 return Err(RuleEngineError::VerifierOnlyState { state });
387 }
388 Ok(Self {
389 predicate,
390 value,
391 prior,
392 strength,
393 state,
394 calibrations,
395 })
396 }
397
398 pub fn predicate(&self) -> &KnowledgePredicate {
400 &self.predicate
401 }
402
403 pub fn value(&self) -> &EvidenceValue {
405 &self.value
406 }
407
408 pub fn prior(&self) -> Probability {
410 self.prior
411 }
412
413 pub fn strength(&self) -> HypothesisStrength {
415 self.strength
416 }
417
418 pub fn state(&self) -> HypothesisState {
420 self.state
421 }
422
423 pub fn calibrations(&self) -> &[EvidenceCalibration] {
425 &self.calibrations
426 }
427}
428
429impl<'de> Deserialize<'de> for HypothesisConclusion {
430 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
431 where
432 D: Deserializer<'de>,
433 {
434 #[derive(Deserialize)]
435 #[serde(deny_unknown_fields)]
436 struct WireConclusion {
437 predicate: KnowledgePredicate,
438 value: EvidenceValue,
439 prior: Probability,
440 strength: HypothesisStrength,
441 state: HypothesisState,
442 calibrations: Vec<EvidenceCalibration>,
443 }
444
445 let wire = WireConclusion::deserialize(deserializer)?;
446 Self::new(
447 wire.predicate,
448 wire.value,
449 wire.prior,
450 wire.strength,
451 wire.state,
452 wire.calibrations,
453 )
454 .map_err(serde::de::Error::custom)
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460pub struct ReasoningRule {
461 pub(super) id: String,
462 pub(super) condition: Expression,
463 pub(super) conclusion: HypothesisConclusion,
464}
465
466impl ReasoningRule {
467 pub fn new(
469 id: impl Into<String>,
470 condition: Expression,
471 conclusion: HypothesisConclusion,
472 ) -> Result<Self, RuleEngineError> {
473 Ok(Self {
474 id: non_empty(id, "rule id")?,
475 condition,
476 conclusion,
477 })
478 }
479
480 pub fn id(&self) -> &str {
482 &self.id
483 }
484
485 pub fn condition(&self) -> &Expression {
487 &self.condition
488 }
489
490 pub fn conclusion(&self) -> &HypothesisConclusion {
492 &self.conclusion
493 }
494}
495
496impl<'de> Deserialize<'de> for ReasoningRule {
497 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
498 where
499 D: Deserializer<'de>,
500 {
501 #[derive(Deserialize)]
502 #[serde(deny_unknown_fields)]
503 struct WireRule {
504 id: String,
505 condition: Expression,
506 conclusion: HypothesisConclusion,
507 }
508
509 let wire = WireRule::deserialize(deserializer)?;
510 Self::new(wire.id, wire.condition, wire.conclusion).map_err(serde::de::Error::custom)
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517#[non_exhaustive]
518pub enum RuleWrite {
519 Inserted,
521 Unchanged,
523}