Skip to main content

termivar_core/
reasoning.rs

1//! Transport-neutral contracts for evidence-driven reasoning.
2//!
3//! These types deliberately contain no scheduling or detection behavior. A
4//! scanner records [`Evidence`], materializes [`Fact`] values, evaluates
5//! [`Hypothesis`] values, and connects [`KnowledgeEntity`] values through
6//! [`KnowledgeRelation`] edges in higher-level crates.
7
8use serde::{Deserialize, Deserializer, Serialize};
9use std::collections::BTreeSet;
10use std::fmt;
11use thiserror::Error;
12
13const MAX_CONFIDENCE_BASIS_POINTS: u16 = 10_000;
14const MAX_PROBABILITY_PARTS_PER_MILLION: u32 = 1_000_000;
15
16/// Maximum distinct parent records one derived evidence record may reference.
17///
18/// Conservative and far above every current consumer (form-control discovery
19/// references exactly one parent). Bounds validation work and index growth.
20pub const MAX_DERIVATION_PARENTS: usize = 32;
21
22/// Maximum byte length of a derivation algorithm identifier.
23pub const MAX_DERIVATION_ALGORITHM_BYTES: usize = 64;
24
25/// Validation errors for decision-engine domain contracts.
26#[derive(Debug, Clone, PartialEq, Eq, Error)]
27#[non_exhaustive]
28pub enum ReasoningModelError {
29    /// A required identifier or name was empty.
30    #[error("{field} must not be empty")]
31    EmptyValue { field: &'static str },
32
33    /// A confidence score exceeded the inclusive `0..=10_000` range.
34    #[error("confidence score {0} exceeds 10,000 basis points")]
35    ConfidenceOutOfRange(u16),
36
37    /// A probability exceeded the inclusive `0..=1_000_000` range.
38    #[error("probability {0} exceeds 1,000,000 parts per million")]
39    ProbabilityOutOfRange(u32),
40
41    /// Bayes' theorem had a zero denominator for the supplied observation.
42    #[error("Bayesian posterior is undefined for the supplied likelihoods")]
43    UndefinedPosterior,
44
45    /// Derived evidence declared no parent record.
46    #[error("derived evidence must reference at least one parent evidence record")]
47    EmptyDerivationParents,
48
49    /// Derived evidence referenced more parents than the compiled bound allows.
50    #[error("derived evidence references {count} parents, exceeding the maximum of {max}")]
51    TooManyDerivationParents {
52        /// Distinct parent count after canonicalization.
53        count: usize,
54        /// Compiled maximum parent count.
55        max: usize,
56    },
57
58    /// A derivation algorithm identifier exceeded the compiled byte bound.
59    #[error("derivation algorithm identifier is {len} bytes, exceeding the maximum of {max}")]
60    DerivationAlgorithmTooLong {
61        /// Identifier length in bytes.
62        len: usize,
63        /// Compiled maximum identifier length in bytes.
64        max: usize,
65    },
66}
67
68fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, ReasoningModelError> {
69    let value = value.into();
70    if value.trim().is_empty() {
71        return Err(ReasoningModelError::EmptyValue { field });
72    }
73    Ok(value)
74}
75
76fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
77where
78    D: Deserializer<'de>,
79{
80    let value = String::deserialize(deserializer)?;
81    non_empty(value, "value").map_err(serde::de::Error::custom)
82}
83
84fn deserialize_optional_non_empty_string<'de, D>(
85    deserializer: D,
86) -> Result<Option<String>, D::Error>
87where
88    D: Deserializer<'de>,
89{
90    Option::<String>::deserialize(deserializer)?
91        .map(|value| non_empty(value, "optional value").map_err(serde::de::Error::custom))
92        .transpose()
93}
94
95fn deserialize_non_empty_evidence_ids<'de, D>(
96    deserializer: D,
97) -> Result<BTreeSet<EvidenceId>, D::Error>
98where
99    D: Deserializer<'de>,
100{
101    let evidence_ids = BTreeSet::<EvidenceId>::deserialize(deserializer)?;
102    if evidence_ids.is_empty() {
103        return Err(serde::de::Error::custom(
104            "evidence_ids must contain at least one evidence id",
105        ));
106    }
107    Ok(evidence_ids)
108}
109
110/// Stable identifier for an entity in the knowledge graph.
111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
112#[serde(transparent)]
113pub struct EntityId(String);
114
115impl EntityId {
116    /// Creates a non-empty entity identifier chosen by the host.
117    pub fn new(value: impl Into<String>) -> Result<Self, ReasoningModelError> {
118        Ok(Self(non_empty(value, "entity id")?))
119    }
120
121    /// Returns the identifier as a string slice.
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127impl fmt::Display for EntityId {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter.write_str(&self.0)
130    }
131}
132
133impl<'de> Deserialize<'de> for EntityId {
134    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
135    where
136        D: Deserializer<'de>,
137    {
138        let value = String::deserialize(deserializer)?;
139        Self::new(value).map_err(serde::de::Error::custom)
140    }
141}
142
143/// Unique identifier for one immutable evidence record.
144#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
145#[serde(transparent)]
146pub struct EvidenceId(String);
147
148impl EvidenceId {
149    /// Generates a new evidence identifier.
150    pub fn new() -> Self {
151        Self(uuid::Uuid::new_v4().to_string())
152    }
153
154    /// Parses a previously persisted non-empty evidence identifier.
155    pub fn parse(value: impl Into<String>) -> Result<Self, ReasoningModelError> {
156        Ok(Self(non_empty(value, "evidence id")?))
157    }
158
159    /// Returns the identifier as a string slice.
160    pub fn as_str(&self) -> &str {
161        &self.0
162    }
163}
164
165impl Default for EvidenceId {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl fmt::Display for EvidenceId {
172    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173        formatter.write_str(&self.0)
174    }
175}
176
177impl<'de> Deserialize<'de> for EvidenceId {
178    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179    where
180        D: Deserializer<'de>,
181    {
182        let value = String::deserialize(deserializer)?;
183        Self::parse(value).map_err(serde::de::Error::custom)
184    }
185}
186
187/// Unique identifier for one knowledge-graph relation.
188#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
189#[serde(transparent)]
190pub struct RelationId(String);
191
192impl RelationId {
193    /// Generates a new relation identifier.
194    pub fn new() -> Self {
195        Self(uuid::Uuid::new_v4().to_string())
196    }
197
198    /// Parses a previously persisted non-empty relation identifier.
199    pub fn parse(value: impl Into<String>) -> Result<Self, ReasoningModelError> {
200        Ok(Self(non_empty(value, "relation id")?))
201    }
202
203    /// Returns the identifier as a string slice.
204    pub fn as_str(&self) -> &str {
205        &self.0
206    }
207}
208
209impl Default for RelationId {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl fmt::Display for RelationId {
216    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
217        formatter.write_str(&self.0)
218    }
219}
220
221impl<'de> Deserialize<'de> for RelationId {
222    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
223    where
224        D: Deserializer<'de>,
225    {
226        let value = String::deserialize(deserializer)?;
227        Self::parse(value).map_err(serde::de::Error::custom)
228    }
229}
230
231/// An ordinal evidence score represented in basis points.
232///
233/// `10_000` means maximum confidence and `0` means no confidence. This value
234/// is not a statistical probability unless a future reasoner explicitly
235/// calibrates it against measured outcomes.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
237#[serde(transparent)]
238pub struct ConfidenceScore(u16);
239
240impl ConfidenceScore {
241    /// No confidence.
242    pub const NONE: Self = Self(0);
243
244    /// Maximum confidence.
245    pub const MAX: Self = Self(MAX_CONFIDENCE_BASIS_POINTS);
246
247    /// Creates a validated score from basis points.
248    pub fn from_basis_points(value: u16) -> Result<Self, ReasoningModelError> {
249        if value > MAX_CONFIDENCE_BASIS_POINTS {
250            return Err(ReasoningModelError::ConfidenceOutOfRange(value));
251        }
252        Ok(Self(value))
253    }
254
255    /// Creates a validated score from an integer percentage.
256    pub fn from_percent(value: u8) -> Result<Self, ReasoningModelError> {
257        Self::from_basis_points(u16::from(value) * 100)
258    }
259
260    /// Returns the score in basis points.
261    pub const fn basis_points(self) -> u16 {
262        self.0
263    }
264
265    /// Returns the score as a ratio in the inclusive `0.0..=1.0` range.
266    pub fn ratio(self) -> f64 {
267        f64::from(self.0) / f64::from(MAX_CONFIDENCE_BASIS_POINTS)
268    }
269}
270
271impl<'de> Deserialize<'de> for ConfidenceScore {
272    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
273    where
274        D: Deserializer<'de>,
275    {
276        let value = u16::deserialize(deserializer)?;
277        Self::from_basis_points(value).map_err(serde::de::Error::custom)
278    }
279}
280
281/// A bounded probability value represented as fixed-point parts per million.
282///
283/// Unlike [`ConfidenceScore`], this type can represent priors and conditional
284/// likelihoods in Bayesian updates. The type enforces range and deterministic
285/// arithmetic; it does not prove that a caller's values were empirically
286/// calibrated. Profiles using policy-selected weights must document that fact
287/// rather than presenting their posteriors as measured real-world frequencies.
288/// Floating-point conversion is provided only for display and analytics.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
290#[serde(transparent)]
291pub struct Probability(u32);
292
293impl Probability {
294    /// An impossible event.
295    pub const ZERO: Self = Self(0);
296
297    /// A certain event.
298    pub const ONE: Self = Self(MAX_PROBABILITY_PARTS_PER_MILLION);
299
300    /// Creates a probability from fixed-point parts per million.
301    pub fn from_parts_per_million(value: u32) -> Result<Self, ReasoningModelError> {
302        if value > MAX_PROBABILITY_PARTS_PER_MILLION {
303            return Err(ReasoningModelError::ProbabilityOutOfRange(value));
304        }
305        Ok(Self(value))
306    }
307
308    /// Creates a probability from basis points.
309    pub fn from_basis_points(value: u16) -> Result<Self, ReasoningModelError> {
310        Self::from_parts_per_million(u32::from(value) * 100)
311    }
312
313    /// Creates a probability from an integer percentage.
314    pub fn from_percent(value: u8) -> Result<Self, ReasoningModelError> {
315        Self::from_parts_per_million(u32::from(value) * 10_000)
316    }
317
318    /// Returns the fixed-point value in parts per million.
319    pub const fn parts_per_million(self) -> u32 {
320        self.0
321    }
322
323    /// Returns the probability as a ratio for presentation or analytics.
324    pub fn ratio(self) -> f64 {
325        f64::from(self.0) / f64::from(MAX_PROBABILITY_PARTS_PER_MILLION)
326    }
327
328    /// Applies one Bayesian observation to this prior.
329    ///
330    /// `likelihood_if_true` is `P(E|H)` and `likelihood_if_false` is
331    /// `P(E|not H)`. The result is rounded to the nearest part per million.
332    pub fn update(
333        self,
334        likelihood_if_true: Self,
335        likelihood_if_false: Self,
336    ) -> Result<Self, ReasoningModelError> {
337        let scale = u128::from(MAX_PROBABILITY_PARTS_PER_MILLION);
338        let prior = u128::from(self.0);
339        let true_weight = u128::from(likelihood_if_true.0) * prior;
340        let false_weight = u128::from(likelihood_if_false.0) * (scale - prior);
341        let denominator = true_weight + false_weight;
342
343        if denominator == 0 {
344            return Err(ReasoningModelError::UndefinedPosterior);
345        }
346
347        let rounded = (true_weight * scale + denominator / 2) / denominator;
348        let value = u32::try_from(rounded).expect("posterior is bounded by the probability scale");
349        Self::from_parts_per_million(value)
350    }
351}
352
353impl<'de> Deserialize<'de> for Probability {
354    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
355    where
356        D: Deserializer<'de>,
357    {
358        let value = u32::deserialize(deserializer)?;
359        Self::from_parts_per_million(value).map_err(serde::de::Error::custom)
360    }
361}
362
363/// Namespaced predicate used by evidence, facts, and hypotheses.
364///
365/// Examples include `http.header.x-powered-by`, `service.port`, and
366/// `technology.framework`.
367#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
368pub struct KnowledgePredicate {
369    namespace: String,
370    name: String,
371}
372
373impl KnowledgePredicate {
374    /// Creates a predicate from non-empty namespace and name components.
375    pub fn new(
376        namespace: impl Into<String>,
377        name: impl Into<String>,
378    ) -> Result<Self, ReasoningModelError> {
379        Ok(Self {
380            namespace: non_empty(namespace, "predicate namespace")?,
381            name: non_empty(name, "predicate name")?,
382        })
383    }
384
385    /// Returns the predicate namespace.
386    pub fn namespace(&self) -> &str {
387        &self.namespace
388    }
389
390    /// Returns the predicate name within its namespace.
391    pub fn name(&self) -> &str {
392        &self.name
393    }
394
395    /// Returns the stable dotted form used in explanations.
396    pub fn dotted(&self) -> String {
397        format!("{}.{}", self.namespace, self.name)
398    }
399}
400
401impl<'de> Deserialize<'de> for KnowledgePredicate {
402    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
403    where
404        D: Deserializer<'de>,
405    {
406        #[derive(Deserialize)]
407        struct WirePredicate {
408            namespace: String,
409            name: String,
410        }
411
412        let wire = WirePredicate::deserialize(deserializer)?;
413        Self::new(wire.namespace, wire.name).map_err(serde::de::Error::custom)
414    }
415}
416
417/// Typed value carried by evidence and claims.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(tag = "type", content = "value", rename_all = "snake_case")]
420#[non_exhaustive]
421pub enum EvidenceValue {
422    /// Boolean signal.
423    Boolean(bool),
424    /// Signed integer measurement.
425    Signed(i64),
426    /// Unsigned integer measurement.
427    Unsigned(u64),
428    /// UTF-8 text value.
429    Text(String),
430    /// Ordered collection of UTF-8 text values.
431    TextList(Vec<String>),
432}
433
434/// Broad evidence category used for routing and policy.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437#[non_exhaustive]
438pub enum EvidenceKind {
439    /// Host, port, protocol, or service observation.
440    Network,
441    /// HTTP request or response observation.
442    Http,
443    /// TLS or certificate observation.
444    Tls,
445    /// DNS observation.
446    Dns,
447    /// Response body, script, robots, or sitemap observation.
448    Content,
449    /// Authentication or session observation.
450    Authentication,
451    /// Rate-limit or backpressure observation.
452    RateLimit,
453    /// Latency or timing observation.
454    Timing,
455    /// Technology fingerprint observation.
456    Technology,
457    /// Extension category with a stable namespaced identifier.
458    Custom(String),
459}
460
461/// Provenance identifying who produced an evidence record and how.
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463pub struct EvidenceSource {
464    #[serde(deserialize_with = "deserialize_non_empty_string")]
465    component: String,
466    #[serde(deserialize_with = "deserialize_non_empty_string")]
467    method: String,
468    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
469    correlation_id: Option<String>,
470}
471
472impl EvidenceSource {
473    /// Creates a source from a component and observation method.
474    pub fn new(
475        component: impl Into<String>,
476        method: impl Into<String>,
477    ) -> Result<Self, ReasoningModelError> {
478        Ok(Self {
479            component: non_empty(component, "evidence source component")?,
480            method: non_empty(method, "evidence source method")?,
481            correlation_id: None,
482        })
483    }
484
485    /// Associates this source with a scan or request correlation ID.
486    pub fn with_correlation_id(
487        mut self,
488        correlation_id: impl Into<String>,
489    ) -> Result<Self, ReasoningModelError> {
490        self.correlation_id = Some(non_empty(correlation_id, "correlation id")?);
491        Ok(self)
492    }
493
494    /// Returns the producing component.
495    pub fn component(&self) -> &str {
496        &self.component
497    }
498
499    /// Returns the observation method.
500    pub fn method(&self) -> &str {
501        &self.method
502    }
503
504    /// Returns the optional scan or request correlation ID.
505    pub fn correlation_id(&self) -> Option<&str> {
506        self.correlation_id.as_deref()
507    }
508}
509
510/// Stable identity of the transformation that produced a derived evidence
511/// record.
512///
513/// The `name` is a bounded, non-empty, stable identifier (for example
514/// `http.form-control-names`). The `version` distinguishes incompatible
515/// revisions of the same transformation so a consumer can tell exactly which
516/// algorithm produced a child, not merely that some transformation did.
517#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
518pub struct DerivationAlgorithm {
519    name: String,
520    version: u32,
521}
522
523impl DerivationAlgorithm {
524    /// Creates a validated algorithm identity from a bounded name and version.
525    pub fn new(name: impl Into<String>, version: u32) -> Result<Self, ReasoningModelError> {
526        let name = non_empty(name, "derivation algorithm name")?;
527        if name.len() > MAX_DERIVATION_ALGORITHM_BYTES {
528            return Err(ReasoningModelError::DerivationAlgorithmTooLong {
529                len: name.len(),
530                max: MAX_DERIVATION_ALGORITHM_BYTES,
531            });
532        }
533        Ok(Self { name, version })
534    }
535
536    /// Returns the stable algorithm name.
537    pub fn name(&self) -> &str {
538        &self.name
539    }
540
541    /// Returns the algorithm revision.
542    pub fn version(&self) -> u32 {
543        self.version
544    }
545}
546
547impl<'de> Deserialize<'de> for DerivationAlgorithm {
548    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549    where
550        D: Deserializer<'de>,
551    {
552        #[derive(Deserialize)]
553        #[serde(deny_unknown_fields)]
554        struct Wire {
555            name: String,
556            version: u32,
557        }
558        let wire = Wire::deserialize(deserializer)?;
559        Self::new(wire.name, wire.version).map_err(serde::de::Error::custom)
560    }
561}
562
563/// The exact immutable evidence record(s) a derived record was computed from.
564///
565/// This is **derivation lineage**, not producer provenance, case correlation,
566/// or reasoning support: it names the precise transformation inputs. Parents
567/// are stored canonicalized (sorted, de-duplicated) so acceptance never depends
568/// on input order and an equivalent lineage is a single stable value. Structural
569/// validity (non-empty, bounded, canonical) is enforced here; contextual
570/// validity that requires the knowledge store (parent existence, self-reference,
571/// cycles, subject agreement) is enforced atomically at insertion time.
572#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
573pub struct EvidenceDerivation {
574    parents: Vec<EvidenceId>,
575    algorithm: DerivationAlgorithm,
576}
577
578impl EvidenceDerivation {
579    /// Creates a validated derivation from one or more parent evidence IDs.
580    ///
581    /// Duplicate parents are canonicalized to a single occurrence; the bound is
582    /// applied to the distinct set. An empty parent set is rejected — a record
583    /// with no parents is direct evidence, never a zero-parent derivation.
584    pub fn new(
585        parents: impl IntoIterator<Item = EvidenceId>,
586        algorithm: DerivationAlgorithm,
587    ) -> Result<Self, ReasoningModelError> {
588        let canonical: BTreeSet<EvidenceId> = parents.into_iter().collect();
589        if canonical.is_empty() {
590            return Err(ReasoningModelError::EmptyDerivationParents);
591        }
592        if canonical.len() > MAX_DERIVATION_PARENTS {
593            return Err(ReasoningModelError::TooManyDerivationParents {
594                count: canonical.len(),
595                max: MAX_DERIVATION_PARENTS,
596            });
597        }
598        Ok(Self {
599            parents: canonical.into_iter().collect(),
600            algorithm,
601        })
602    }
603
604    /// Returns the canonical (sorted, de-duplicated) parent evidence IDs.
605    pub fn parents(&self) -> &[EvidenceId] {
606        &self.parents
607    }
608
609    /// Returns the transformation identity that produced the child.
610    pub fn algorithm(&self) -> &DerivationAlgorithm {
611        &self.algorithm
612    }
613
614    /// Returns whether the given evidence ID is a parent of this derivation.
615    pub fn references_parent(&self, id: &EvidenceId) -> bool {
616        self.parents.binary_search(id).is_ok()
617    }
618}
619
620impl<'de> Deserialize<'de> for EvidenceDerivation {
621    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
622    where
623        D: Deserializer<'de>,
624    {
625        #[derive(Deserialize)]
626        #[serde(deny_unknown_fields)]
627        struct Wire {
628            parents: Vec<EvidenceId>,
629            algorithm: DerivationAlgorithm,
630        }
631        let wire = Wire::deserialize(deserializer)?;
632        Self::new(wire.parents, wire.algorithm).map_err(serde::de::Error::custom)
633    }
634}
635
636/// Whether an evidence record was directly observed or derived from other
637/// records.
638///
639/// `Direct` is the default and the historical meaning of every evidence record.
640/// `Derived` carries exact lineage. This distinction is authoritative in the
641/// live knowledge store; it is intentionally **not** part of the serialized
642/// [`Evidence`] wire in this revision (see [`Evidence`]).
643#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
644#[serde(rename_all = "snake_case")]
645#[non_exhaustive]
646pub enum EvidenceOrigin {
647    /// A first-hand observation with no derivation lineage.
648    #[default]
649    Direct,
650    /// A record computed from exact parent evidence record(s).
651    Derived(EvidenceDerivation),
652}
653
654impl EvidenceOrigin {
655    /// Returns whether this is a direct (non-derived) observation.
656    pub fn is_direct(&self) -> bool {
657        matches!(self, Self::Direct)
658    }
659
660    /// Returns the derivation lineage when this record is derived.
661    pub fn derivation(&self) -> Option<&EvidenceDerivation> {
662        match self {
663            Self::Derived(derivation) => Some(derivation),
664            Self::Direct => None,
665        }
666    }
667}
668
669/// Immutable observation recorded by discovery or execution code.
670///
671/// # Lineage
672///
673/// An evidence record is [`EvidenceOrigin::Direct`] by default. A producer that
674/// computes a record from exact source records attaches lineage with
675/// [`Self::derived_from`]. Lineage is **runtime truth held in the live
676/// knowledge store**: the `origin` is deliberately excluded from the serialized
677/// wire, so the serialized form of every record — direct or derived — is
678/// byte-identical to the historical contract. Durable lineage export is a
679/// future versioned surface, not this record's wire.
680///
681/// # Examples
682///
683/// ```rust
684/// use termivar_core::{
685///     ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource,
686///     EvidenceValue, KnowledgePredicate,
687/// };
688///
689/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
690/// let evidence = Evidence::new(
691///     EntityId::new("endpoint:https://example.test")?,
692///     EvidenceKind::Http,
693///     KnowledgePredicate::new("http.header", "server")?,
694///     EvidenceValue::Text("nginx".into()),
695///     EvidenceSource::new("discovery.headers", "server-header")?,
696///     ConfidenceScore::from_percent(85)?,
697/// );
698///
699/// assert_eq!(evidence.reliability().basis_points(), 8_500);
700/// # Ok(())
701/// # }
702/// ```
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
704pub struct Evidence {
705    id: EvidenceId,
706    subject: EntityId,
707    kind: EvidenceKind,
708    predicate: KnowledgePredicate,
709    value: EvidenceValue,
710    source: EvidenceSource,
711    reliability: ConfidenceScore,
712    observed_at_ms: u64,
713    // Derivation lineage is runtime truth: it participates in structural
714    // equality (so reusing an ID with a different origin is an identity
715    // conflict) but is intentionally excluded from the serialized wire, keeping
716    // the direct-evidence contract byte-identical and refusing to encode a
717    // strippable derived/direct discriminator on the wire.
718    #[serde(skip)]
719    origin: EvidenceOrigin,
720}
721
722impl Evidence {
723    /// Records evidence with an explicit source reliability and a generated ID.
724    pub fn new(
725        subject: EntityId,
726        kind: EvidenceKind,
727        predicate: KnowledgePredicate,
728        value: EvidenceValue,
729        source: EvidenceSource,
730        reliability: ConfidenceScore,
731    ) -> Self {
732        Self::with_id(
733            EvidenceId::new(),
734            subject,
735            kind,
736            predicate,
737            value,
738            source,
739            reliability,
740        )
741    }
742
743    /// Records evidence with a host-assigned stable identity.
744    ///
745    /// Reusing the ID for different evidence remains an identity conflict in
746    /// the knowledge store. Producers that also need a stable observation
747    /// timestamp should use [`Self::with_id_at`].
748    pub fn with_id(
749        id: EvidenceId,
750        subject: EntityId,
751        kind: EvidenceKind,
752        predicate: KnowledgePredicate,
753        value: EvidenceValue,
754        source: EvidenceSource,
755        reliability: ConfidenceScore,
756    ) -> Self {
757        Self::with_id_at(
758            id,
759            subject,
760            kind,
761            predicate,
762            value,
763            source,
764            reliability,
765            now_ms(),
766        )
767    }
768
769    /// Records evidence with host-assigned identity and observation time.
770    ///
771    /// Supplying both fields allows a deterministic producer to recreate an
772    /// exactly equal evidence record for idempotent insertion.
773    #[allow(clippy::too_many_arguments)]
774    pub fn with_id_at(
775        id: EvidenceId,
776        subject: EntityId,
777        kind: EvidenceKind,
778        predicate: KnowledgePredicate,
779        value: EvidenceValue,
780        source: EvidenceSource,
781        reliability: ConfidenceScore,
782        observed_at_ms: u64,
783    ) -> Self {
784        Self {
785            id,
786            subject,
787            kind,
788            predicate,
789            value,
790            source,
791            reliability,
792            observed_at_ms,
793            origin: EvidenceOrigin::Direct,
794        }
795    }
796
797    /// Marks this record as derived from exact parent evidence record(s).
798    ///
799    /// Structural validity of the lineage is enforced when the
800    /// [`EvidenceDerivation`] is constructed; contextual validity (parent
801    /// existence, subject agreement, self-reference, and cycles) is enforced
802    /// atomically by the knowledge store on insertion.
803    pub fn derived_from(mut self, derivation: EvidenceDerivation) -> Self {
804        self.origin = EvidenceOrigin::Derived(derivation);
805        self
806    }
807
808    /// Returns the evidence identifier.
809    pub fn id(&self) -> &EvidenceId {
810        &self.id
811    }
812
813    /// Returns the entity this observation describes.
814    pub fn subject(&self) -> &EntityId {
815        &self.subject
816    }
817
818    /// Returns the broad evidence category.
819    pub fn kind(&self) -> &EvidenceKind {
820        &self.kind
821    }
822
823    /// Returns the namespaced predicate.
824    pub fn predicate(&self) -> &KnowledgePredicate {
825        &self.predicate
826    }
827
828    /// Returns the typed observation value.
829    pub fn value(&self) -> &EvidenceValue {
830        &self.value
831    }
832
833    /// Returns the observation provenance.
834    pub fn source(&self) -> &EvidenceSource {
835        &self.source
836    }
837
838    /// Returns the source reliability score.
839    pub fn reliability(&self) -> ConfidenceScore {
840        self.reliability
841    }
842
843    /// Returns the observation timestamp in Unix milliseconds.
844    ///
845    /// For a derived record this is the derivation instant, not an independent
846    /// re-observation of the underlying subject.
847    pub fn observed_at_ms(&self) -> u64 {
848        self.observed_at_ms
849    }
850
851    /// Returns whether this record is direct or derived, with exact lineage.
852    pub fn origin(&self) -> &EvidenceOrigin {
853        &self.origin
854    }
855}
856
857/// Materialized claim backed by at least one evidence record.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859pub struct Fact {
860    #[serde(deserialize_with = "deserialize_non_empty_string")]
861    id: String,
862    subject: EntityId,
863    predicate: KnowledgePredicate,
864    value: EvidenceValue,
865    confidence: ConfidenceScore,
866    #[serde(deserialize_with = "deserialize_non_empty_evidence_ids")]
867    evidence_ids: BTreeSet<EvidenceId>,
868    asserted_at_ms: u64,
869}
870
871impl Fact {
872    /// Creates a fact backed by one evidence record.
873    pub fn new(
874        subject: EntityId,
875        predicate: KnowledgePredicate,
876        value: EvidenceValue,
877        confidence: ConfidenceScore,
878        evidence_id: EvidenceId,
879    ) -> Self {
880        Self {
881            id: uuid::Uuid::new_v4().to_string(),
882            subject,
883            predicate,
884            value,
885            confidence,
886            evidence_ids: BTreeSet::from([evidence_id]),
887            asserted_at_ms: now_ms(),
888        }
889    }
890
891    /// Replaces the fact confidence score.
892    pub fn with_confidence(mut self, confidence: ConfidenceScore) -> Self {
893        self.confidence = confidence;
894        self
895    }
896
897    /// Adds provenance without counting the same evidence twice.
898    pub fn add_evidence(&mut self, evidence_id: EvidenceId) {
899        self.evidence_ids.insert(evidence_id);
900    }
901
902    /// Returns the fact identifier.
903    pub fn id(&self) -> &str {
904        &self.id
905    }
906
907    /// Returns the claim subject.
908    pub fn subject(&self) -> &EntityId {
909        &self.subject
910    }
911
912    /// Returns the claim predicate.
913    pub fn predicate(&self) -> &KnowledgePredicate {
914        &self.predicate
915    }
916
917    /// Returns the claim value.
918    pub fn value(&self) -> &EvidenceValue {
919        &self.value
920    }
921
922    /// Returns the confidence score.
923    pub fn confidence(&self) -> ConfidenceScore {
924        self.confidence
925    }
926
927    /// Returns the evidence records supporting this fact.
928    pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
929        &self.evidence_ids
930    }
931
932    /// Returns when the fact was asserted in Unix milliseconds.
933    pub fn asserted_at_ms(&self) -> u64 {
934        self.asserted_at_ms
935    }
936}
937
938/// Direction in which evidence affects a hypothesis.
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
940#[serde(rename_all = "snake_case")]
941#[non_exhaustive]
942pub enum ContributionDirection {
943    /// Evidence supports the claim.
944    Supporting,
945    /// Evidence contradicts the claim.
946    Contradicting,
947}
948
949/// Explainable weighted evidence attached to a hypothesis.
950#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
951pub struct EvidenceContribution {
952    evidence_id: EvidenceId,
953    direction: ContributionDirection,
954    weight: ConfidenceScore,
955    #[serde(deserialize_with = "deserialize_non_empty_string")]
956    rationale: String,
957}
958
959impl EvidenceContribution {
960    /// Creates a contribution with a non-empty explanation.
961    pub fn new(
962        evidence_id: EvidenceId,
963        direction: ContributionDirection,
964        weight: ConfidenceScore,
965        rationale: impl Into<String>,
966    ) -> Result<Self, ReasoningModelError> {
967        Ok(Self {
968            evidence_id,
969            direction,
970            weight,
971            rationale: non_empty(rationale, "evidence contribution rationale")?,
972        })
973    }
974
975    /// Returns the referenced evidence identifier.
976    pub fn evidence_id(&self) -> &EvidenceId {
977        &self.evidence_id
978    }
979
980    /// Returns whether this contribution supports or contradicts the claim.
981    pub fn direction(&self) -> ContributionDirection {
982        self.direction
983    }
984
985    /// Returns the ordinal contribution weight.
986    pub fn weight(&self) -> ConfidenceScore {
987        self.weight
988    }
989
990    /// Returns the human-readable reason for the contribution.
991    pub fn rationale(&self) -> &str {
992        &self.rationale
993    }
994}
995
996/// Likelihoods contributed by one immutable evidence record.
997///
998/// # Example
999///
1000/// ```rust
1001/// use termivar_core::{BayesianEvidence, EvidenceId, Probability};
1002///
1003/// let observation = BayesianEvidence::new(
1004///     EvidenceId::parse("cookie-xsrf-token")?,
1005///     Probability::from_percent(80)?,
1006///     Probability::from_percent(20)?,
1007///     "XSRF-TOKEN is more likely when the framework is present",
1008/// )?;
1009///
1010/// assert_eq!(observation.likelihood_if_true().parts_per_million(), 800_000);
1011/// # Ok::<(), termivar_core::ReasoningModelError>(())
1012/// ```
1013#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct BayesianEvidence {
1015    evidence_id: EvidenceId,
1016    likelihood_if_true: Probability,
1017    likelihood_if_false: Probability,
1018    #[serde(deserialize_with = "deserialize_non_empty_string")]
1019    rationale: String,
1020}
1021
1022impl BayesianEvidence {
1023    /// Creates one explainable likelihood observation.
1024    pub fn new(
1025        evidence_id: EvidenceId,
1026        likelihood_if_true: Probability,
1027        likelihood_if_false: Probability,
1028        rationale: impl Into<String>,
1029    ) -> Result<Self, ReasoningModelError> {
1030        Ok(Self {
1031            evidence_id,
1032            likelihood_if_true,
1033            likelihood_if_false,
1034            rationale: non_empty(rationale, "Bayesian evidence rationale")?,
1035        })
1036    }
1037
1038    /// Returns the referenced evidence identifier.
1039    pub fn evidence_id(&self) -> &EvidenceId {
1040        &self.evidence_id
1041    }
1042
1043    /// Returns `P(E|H)`.
1044    pub fn likelihood_if_true(&self) -> Probability {
1045        self.likelihood_if_true
1046    }
1047
1048    /// Returns `P(E|not H)`.
1049    pub fn likelihood_if_false(&self) -> Probability {
1050        self.likelihood_if_false
1051    }
1052
1053    /// Returns the human-readable reason for these likelihoods.
1054    pub fn rationale(&self) -> &str {
1055        &self.rationale
1056    }
1057}
1058
1059/// Result of adding evidence to a Bayesian belief.
1060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1061#[non_exhaustive]
1062pub enum BeliefWrite {
1063    /// A new evidence identifier was added.
1064    Inserted,
1065    /// Existing likelihoods for the evidence identifier were replaced.
1066    Replaced,
1067    /// The same evidence and likelihoods were already present.
1068    Unchanged,
1069}
1070
1071/// One explainable step in a posterior calculation.
1072#[derive(Debug, Clone, PartialEq, Eq)]
1073pub struct BayesianUpdate {
1074    evidence_id: EvidenceId,
1075    prior: Probability,
1076    likelihood_if_true: Probability,
1077    likelihood_if_false: Probability,
1078    posterior: Probability,
1079    rationale: String,
1080}
1081
1082impl BayesianUpdate {
1083    /// Returns the evidence applied in this step.
1084    pub fn evidence_id(&self) -> &EvidenceId {
1085        &self.evidence_id
1086    }
1087
1088    /// Returns the probability before this observation.
1089    pub fn prior(&self) -> Probability {
1090        self.prior
1091    }
1092
1093    /// Returns `P(E|H)` for this observation.
1094    pub fn likelihood_if_true(&self) -> Probability {
1095        self.likelihood_if_true
1096    }
1097
1098    /// Returns `P(E|not H)` for this observation.
1099    pub fn likelihood_if_false(&self) -> Probability {
1100        self.likelihood_if_false
1101    }
1102
1103    /// Returns the probability after this observation.
1104    pub fn posterior(&self) -> Probability {
1105        self.posterior
1106    }
1107
1108    /// Returns the explanation attached to this observation.
1109    pub fn rationale(&self) -> &str {
1110        &self.rationale
1111    }
1112}
1113
1114/// Deterministic Bayesian state for one hypothesis.
1115///
1116/// Evidence is stored and replayed in [`EvidenceId`] order. Inserting the same
1117/// set in a different arrival order therefore produces an identical posterior
1118/// and audit trail. Reusing an evidence identifier replaces its likelihoods
1119/// instead of double counting the observation.
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1121pub struct BayesianBelief {
1122    prior: Probability,
1123    posterior: Probability,
1124    evidence: Vec<BayesianEvidence>,
1125    #[serde(skip)]
1126    updates: Vec<BayesianUpdate>,
1127}
1128
1129impl BayesianBelief {
1130    /// Starts a belief at the caller-supplied prior.
1131    pub fn new(prior: Probability) -> Self {
1132        Self {
1133            prior,
1134            posterior: prior,
1135            evidence: Vec::new(),
1136            updates: Vec::new(),
1137        }
1138    }
1139
1140    /// Adds or replaces one observation and recomputes the posterior.
1141    ///
1142    /// The operation is transactional: an undefined update leaves the belief
1143    /// unchanged.
1144    pub fn observe(
1145        &mut self,
1146        observation: BayesianEvidence,
1147    ) -> Result<BeliefWrite, ReasoningModelError> {
1148        let mut candidate = self.evidence.clone();
1149        let write = match candidate
1150            .binary_search_by(|existing| existing.evidence_id.cmp(&observation.evidence_id))
1151        {
1152            Ok(index) if candidate[index] == observation => return Ok(BeliefWrite::Unchanged),
1153            Ok(index) => {
1154                candidate[index] = observation;
1155                BeliefWrite::Replaced
1156            },
1157            Err(index) => {
1158                candidate.insert(index, observation);
1159                BeliefWrite::Inserted
1160            },
1161        };
1162        let (posterior, updates) = calculate_bayesian_updates(self.prior, &candidate)?;
1163
1164        self.posterior = posterior;
1165        self.evidence = candidate;
1166        self.updates = updates;
1167        Ok(write)
1168    }
1169
1170    /// Returns the initial probability before observations.
1171    pub fn prior(&self) -> Probability {
1172        self.prior
1173    }
1174
1175    /// Returns the current posterior probability.
1176    pub fn posterior(&self) -> Probability {
1177        self.posterior
1178    }
1179
1180    /// Returns observations in deterministic evidence-identifier order.
1181    pub fn evidence(&self) -> &[BayesianEvidence] {
1182        &self.evidence
1183    }
1184
1185    /// Returns every intermediate Bayesian update in evaluation order.
1186    pub fn updates(&self) -> &[BayesianUpdate] {
1187        &self.updates
1188    }
1189}
1190
1191impl<'de> Deserialize<'de> for BayesianBelief {
1192    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1193    where
1194        D: Deserializer<'de>,
1195    {
1196        #[derive(Deserialize)]
1197        struct WireBelief {
1198            prior: Probability,
1199            posterior: Probability,
1200            evidence: Vec<BayesianEvidence>,
1201        }
1202
1203        let wire = WireBelief::deserialize(deserializer)?;
1204        let mut belief = Self::new(wire.prior);
1205        let mut evidence_ids = BTreeSet::new();
1206        for observation in wire.evidence {
1207            if !evidence_ids.insert(observation.evidence_id.clone()) {
1208                return Err(serde::de::Error::custom(format!(
1209                    "duplicate Bayesian evidence id {}",
1210                    observation.evidence_id
1211                )));
1212            }
1213            belief
1214                .observe(observation)
1215                .map_err(serde::de::Error::custom)?;
1216        }
1217        if belief.posterior != wire.posterior {
1218            return Err(serde::de::Error::custom(format!(
1219                "serialized posterior {} does not match computed posterior {}",
1220                wire.posterior.parts_per_million(),
1221                belief.posterior.parts_per_million()
1222            )));
1223        }
1224        Ok(belief)
1225    }
1226}
1227
1228fn calculate_bayesian_updates(
1229    prior: Probability,
1230    evidence: &[BayesianEvidence],
1231) -> Result<(Probability, Vec<BayesianUpdate>), ReasoningModelError> {
1232    let mut current = prior;
1233    let mut updates = Vec::with_capacity(evidence.len());
1234    for observation in evidence {
1235        let posterior = current.update(
1236            observation.likelihood_if_true,
1237            observation.likelihood_if_false,
1238        )?;
1239        updates.push(BayesianUpdate {
1240            evidence_id: observation.evidence_id.clone(),
1241            prior: current,
1242            likelihood_if_true: observation.likelihood_if_true,
1243            likelihood_if_false: observation.likelihood_if_false,
1244            posterior,
1245            rationale: observation.rationale.clone(),
1246        });
1247        current = posterior;
1248    }
1249    Ok((current, updates))
1250}
1251
1252/// Lifecycle state for an evaluated hypothesis.
1253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1254#[serde(rename_all = "snake_case")]
1255#[non_exhaustive]
1256pub enum HypothesisState {
1257    /// The claim has not accumulated meaningful evidence yet.
1258    Proposed,
1259    /// Current evidence supports the claim.
1260    Supported,
1261    /// Current evidence weakens or conflicts with the claim.
1262    Contradicted,
1263    /// A verifier confirmed the claim.
1264    Confirmed,
1265    /// A verifier rejected the claim.
1266    Rejected,
1267}
1268
1269/// Rule-assigned evidence strength for a hypothesis.
1270///
1271/// Strength is intentionally separate from posterior probability. A rule can
1272/// require independent evidence sources before marking a highly probable claim
1273/// as strong.
1274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1275#[serde(rename_all = "snake_case")]
1276#[non_exhaustive]
1277pub enum HypothesisStrength {
1278    /// Evidence has not yet satisfied the rule's strong-evidence criteria.
1279    #[default]
1280    Weak,
1281    /// The rule considers the evidence sufficiently independent and specific.
1282    Strong,
1283}
1284
1285/// Explainable claim whose belief is maintained by a reasoning engine.
1286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1287pub struct Hypothesis {
1288    #[serde(deserialize_with = "deserialize_non_empty_string")]
1289    id: String,
1290    subject: EntityId,
1291    predicate: KnowledgePredicate,
1292    value: EvidenceValue,
1293    belief: BayesianBelief,
1294    strength: HypothesisStrength,
1295    state: HypothesisState,
1296    updated_at_ms: u64,
1297}
1298
1299impl Hypothesis {
1300    /// Creates a weak, proposed claim at the supplied prior.
1301    pub fn new(
1302        subject: EntityId,
1303        predicate: KnowledgePredicate,
1304        value: EvidenceValue,
1305        prior: Probability,
1306    ) -> Self {
1307        Self::with_id(
1308            uuid::Uuid::new_v4().to_string(),
1309            subject,
1310            predicate,
1311            value,
1312            prior,
1313        )
1314        .expect("generated UUID is a non-empty hypothesis identifier")
1315    }
1316
1317    /// Creates a hypothesis with a stable host-assigned identifier.
1318    ///
1319    /// Deterministic decision engines should use this constructor so repeated
1320    /// evaluation updates one hypothesis instead of creating duplicates.
1321    pub fn with_id(
1322        id: impl Into<String>,
1323        subject: EntityId,
1324        predicate: KnowledgePredicate,
1325        value: EvidenceValue,
1326        prior: Probability,
1327    ) -> Result<Self, ReasoningModelError> {
1328        Ok(Self {
1329            id: non_empty(id, "hypothesis id")?,
1330            subject,
1331            predicate,
1332            value,
1333            belief: BayesianBelief::new(prior),
1334            strength: HypothesisStrength::Weak,
1335            state: HypothesisState::Proposed,
1336            updated_at_ms: now_ms(),
1337        })
1338    }
1339
1340    /// Applies one observation to the Bayesian belief.
1341    pub fn observe(
1342        &mut self,
1343        observation: BayesianEvidence,
1344    ) -> Result<BeliefWrite, ReasoningModelError> {
1345        let write = self.belief.observe(observation)?;
1346        if write != BeliefWrite::Unchanged {
1347            self.updated_at_ms = now_ms();
1348        }
1349        Ok(write)
1350    }
1351
1352    /// Replaces the strength assigned by a deterministic rule.
1353    pub fn set_strength(&mut self, strength: HypothesisStrength) {
1354        self.strength = strength;
1355        self.updated_at_ms = now_ms();
1356    }
1357
1358    /// Replaces the lifecycle state assigned by the reasoning engine or verifier.
1359    pub fn set_state(&mut self, state: HypothesisState) {
1360        self.state = state;
1361        self.updated_at_ms = now_ms();
1362    }
1363
1364    /// Returns the hypothesis identifier.
1365    pub fn id(&self) -> &str {
1366        &self.id
1367    }
1368
1369    /// Returns the claim subject.
1370    pub fn subject(&self) -> &EntityId {
1371        &self.subject
1372    }
1373
1374    /// Returns the claim predicate.
1375    pub fn predicate(&self) -> &KnowledgePredicate {
1376        &self.predicate
1377    }
1378
1379    /// Returns the claim value.
1380    pub fn value(&self) -> &EvidenceValue {
1381        &self.value
1382    }
1383
1384    /// Returns the complete Bayesian belief and audit trail.
1385    pub fn belief(&self) -> &BayesianBelief {
1386        &self.belief
1387    }
1388
1389    /// Returns the caller- or policy-supplied prior probability.
1390    pub fn prior(&self) -> Probability {
1391        self.belief.prior()
1392    }
1393
1394    /// Returns the current posterior probability.
1395    pub fn posterior(&self) -> Probability {
1396        self.belief.posterior()
1397    }
1398
1399    /// Returns the rule-assigned evidence strength.
1400    pub fn strength(&self) -> HypothesisStrength {
1401        self.strength
1402    }
1403
1404    /// Returns the current evaluation state.
1405    pub fn state(&self) -> HypothesisState {
1406        self.state
1407    }
1408
1409    /// Returns when the hypothesis last changed in Unix milliseconds.
1410    pub fn updated_at_ms(&self) -> u64 {
1411        self.updated_at_ms
1412    }
1413
1414    /// Returns whether two records carry the same decision state.
1415    ///
1416    /// Wall-clock update timestamps are deliberately ignored so deterministic
1417    /// re-evaluation can remain idempotent.
1418    pub fn same_evaluation_as(&self, other: &Self) -> bool {
1419        self.id == other.id
1420            && self.subject == other.subject
1421            && self.predicate == other.predicate
1422            && self.value == other.value
1423            && self.belief == other.belief
1424            && self.strength == other.strength
1425            && self.state == other.state
1426    }
1427}
1428
1429/// Entity categories understood by the knowledge graph.
1430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1431#[serde(rename_all = "snake_case")]
1432#[non_exhaustive]
1433pub enum EntityKind {
1434    /// Network host or DNS name.
1435    Host,
1436    /// Protocol service exposed by a host.
1437    Service,
1438    /// Addressable application endpoint.
1439    Endpoint,
1440    /// Detected language, framework, server, or component.
1441    Technology,
1442    /// User, service account, or other principal.
1443    Identity,
1444    /// Authentication or application session.
1445    Session,
1446    /// Request or protocol input parameter.
1447    Parameter,
1448    /// Secret, token, key, or other credential material.
1449    Credential,
1450    /// Extension entity category with a stable identifier.
1451    Custom(String),
1452}
1453
1454/// A typed node in the future knowledge graph.
1455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1456pub struct KnowledgeEntity {
1457    id: EntityId,
1458    kind: EntityKind,
1459    #[serde(deserialize_with = "deserialize_non_empty_string")]
1460    label: String,
1461}
1462
1463impl KnowledgeEntity {
1464    /// Creates an entity with a non-empty display label.
1465    pub fn new(
1466        id: EntityId,
1467        kind: EntityKind,
1468        label: impl Into<String>,
1469    ) -> Result<Self, ReasoningModelError> {
1470        Ok(Self {
1471            id,
1472            kind,
1473            label: non_empty(label, "entity label")?,
1474        })
1475    }
1476
1477    /// Returns the entity identifier.
1478    pub fn id(&self) -> &EntityId {
1479        &self.id
1480    }
1481
1482    /// Returns the entity category.
1483    pub fn kind(&self) -> &EntityKind {
1484        &self.kind
1485    }
1486
1487    /// Returns the human-readable label.
1488    pub fn label(&self) -> &str {
1489        &self.label
1490    }
1491}
1492
1493/// Relationship categories understood by the knowledge graph.
1494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1495#[serde(rename_all = "snake_case")]
1496#[non_exhaustive]
1497pub enum RelationKind {
1498    /// A host exposes a service.
1499    Exposes,
1500    /// A service serves an endpoint or resource.
1501    Serves,
1502    /// One entity uses another.
1503    Uses,
1504    /// One entity depends on another.
1505    DependsOn,
1506    /// One entity contains another.
1507    Contains,
1508    /// An entity authenticates using another entity.
1509    AuthenticatesWith,
1510    /// An entity or claim was derived from another.
1511    DerivedFrom,
1512    /// Generic association when no stronger relation is known.
1513    RelatedTo,
1514    /// Extension relation category with a stable identifier.
1515    Custom(String),
1516}
1517
1518/// Evidence-backed directed edge between two knowledge entities.
1519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1520pub struct KnowledgeRelation {
1521    id: RelationId,
1522    from: EntityId,
1523    to: EntityId,
1524    kind: RelationKind,
1525    confidence: ConfidenceScore,
1526    #[serde(deserialize_with = "deserialize_non_empty_evidence_ids")]
1527    evidence_ids: BTreeSet<EvidenceId>,
1528}
1529
1530impl KnowledgeRelation {
1531    /// Creates a directed relation backed by one evidence record.
1532    pub fn new(
1533        from: EntityId,
1534        to: EntityId,
1535        kind: RelationKind,
1536        confidence: ConfidenceScore,
1537        evidence_id: EvidenceId,
1538    ) -> Self {
1539        Self::with_id(RelationId::new(), from, to, kind, confidence, evidence_id)
1540    }
1541
1542    /// Creates a directed relation with a caller-supplied stable identifier.
1543    pub fn with_id(
1544        id: RelationId,
1545        from: EntityId,
1546        to: EntityId,
1547        kind: RelationKind,
1548        confidence: ConfidenceScore,
1549        evidence_id: EvidenceId,
1550    ) -> Self {
1551        Self {
1552            id,
1553            from,
1554            to,
1555            kind,
1556            confidence,
1557            evidence_ids: BTreeSet::from([evidence_id]),
1558        }
1559    }
1560
1561    /// Adds provenance without counting the same evidence twice.
1562    pub fn add_evidence(&mut self, evidence_id: EvidenceId) {
1563        self.evidence_ids.insert(evidence_id);
1564    }
1565
1566    /// Returns the relation identifier.
1567    pub fn id(&self) -> &RelationId {
1568        &self.id
1569    }
1570
1571    /// Returns the source entity identifier.
1572    pub fn from(&self) -> &EntityId {
1573        &self.from
1574    }
1575
1576    /// Returns the destination entity identifier.
1577    pub fn to(&self) -> &EntityId {
1578        &self.to
1579    }
1580
1581    /// Returns the relation category.
1582    pub fn kind(&self) -> &RelationKind {
1583        &self.kind
1584    }
1585
1586    /// Returns the relation confidence score.
1587    pub fn confidence(&self) -> ConfidenceScore {
1588        self.confidence
1589    }
1590
1591    /// Returns the evidence records supporting this edge.
1592    pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
1593        &self.evidence_ids
1594    }
1595}
1596
1597fn now_ms() -> u64 {
1598    let milliseconds = std::time::SystemTime::now()
1599        .duration_since(std::time::UNIX_EPOCH)
1600        .unwrap_or_default()
1601        .as_millis();
1602    u64::try_from(milliseconds).unwrap_or(u64::MAX)
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607    use super::*;
1608
1609    fn subject() -> EntityId {
1610        EntityId::new("endpoint:https://example.test/api").unwrap()
1611    }
1612
1613    fn predicate() -> KnowledgePredicate {
1614        KnowledgePredicate::new("technology", "framework").unwrap()
1615    }
1616
1617    fn source() -> EvidenceSource {
1618        EvidenceSource::new("fingerprint.headers", "x-powered-by")
1619            .unwrap()
1620            .with_correlation_id("scan-42")
1621            .unwrap()
1622    }
1623
1624    fn evidence() -> Evidence {
1625        Evidence::new(
1626            subject(),
1627            EvidenceKind::Technology,
1628            predicate(),
1629            EvidenceValue::Text("Laravel".into()),
1630            source(),
1631            ConfidenceScore::from_percent(90).unwrap(),
1632        )
1633    }
1634
1635    #[test]
1636    fn confidence_rejects_out_of_range_values() {
1637        assert_eq!(
1638            ConfidenceScore::from_basis_points(10_001),
1639            Err(ReasoningModelError::ConfidenceOutOfRange(10_001))
1640        );
1641        assert_eq!(
1642            ConfidenceScore::from_percent(101),
1643            Err(ReasoningModelError::ConfidenceOutOfRange(10_100))
1644        );
1645    }
1646
1647    #[test]
1648    fn confidence_rejects_invalid_wire_values() {
1649        assert!(serde_json::from_str::<ConfidenceScore>("10001").is_err());
1650        assert_eq!(
1651            serde_json::from_str::<ConfidenceScore>("8200").unwrap(),
1652            ConfidenceScore::from_percent(82).unwrap()
1653        );
1654    }
1655
1656    #[test]
1657    fn probability_rejects_out_of_range_values() {
1658        assert_eq!(
1659            Probability::from_parts_per_million(1_000_001),
1660            Err(ReasoningModelError::ProbabilityOutOfRange(1_000_001))
1661        );
1662        assert_eq!(
1663            Probability::from_percent(101),
1664            Err(ReasoningModelError::ProbabilityOutOfRange(1_010_000))
1665        );
1666        assert!(serde_json::from_str::<Probability>("1000001").is_err());
1667    }
1668
1669    #[test]
1670    fn probability_applies_bayes_theorem_with_fixed_point_rounding() {
1671        let posterior = Probability::from_percent(10)
1672            .unwrap()
1673            .update(
1674                Probability::from_percent(80).unwrap(),
1675                Probability::from_percent(20).unwrap(),
1676            )
1677            .unwrap();
1678
1679        assert_eq!(posterior.parts_per_million(), 307_692);
1680        assert_eq!(
1681            Probability::ZERO.update(Probability::ZERO, Probability::ZERO),
1682            Err(ReasoningModelError::UndefinedPosterior)
1683        );
1684    }
1685
1686    #[test]
1687    fn evidence_round_trip_preserves_provenance() {
1688        let evidence = evidence();
1689        let encoded = serde_json::to_string(&evidence).unwrap();
1690        let decoded: Evidence = serde_json::from_str(&encoded).unwrap();
1691
1692        assert_eq!(decoded, evidence);
1693        assert_eq!(decoded.source().component(), "fingerprint.headers");
1694        assert_eq!(decoded.source().correlation_id(), Some("scan-42"));
1695        assert_eq!(decoded.reliability().basis_points(), 9_000);
1696    }
1697
1698    fn algorithm() -> DerivationAlgorithm {
1699        DerivationAlgorithm::new("test.transform", 1).unwrap()
1700    }
1701
1702    #[test]
1703    fn derivation_algorithm_validates_name_bounds() {
1704        assert_eq!(
1705            DerivationAlgorithm::new("  ", 1),
1706            Err(ReasoningModelError::EmptyValue {
1707                field: "derivation algorithm name"
1708            })
1709        );
1710        let long = "a".repeat(MAX_DERIVATION_ALGORITHM_BYTES + 1);
1711        assert!(matches!(
1712            DerivationAlgorithm::new(long, 1),
1713            Err(ReasoningModelError::DerivationAlgorithmTooLong { .. })
1714        ));
1715    }
1716
1717    #[test]
1718    fn derivation_rejects_empty_parent_set() {
1719        assert_eq!(
1720            EvidenceDerivation::new(Vec::new(), algorithm()),
1721            Err(ReasoningModelError::EmptyDerivationParents)
1722        );
1723    }
1724
1725    #[test]
1726    fn derivation_canonicalizes_parents_order_independently() {
1727        let a = EvidenceId::parse("id-a").unwrap();
1728        let b = EvidenceId::parse("id-b").unwrap();
1729        let forward =
1730            EvidenceDerivation::new([a.clone(), b.clone(), a.clone()], algorithm()).unwrap();
1731        let reverse = EvidenceDerivation::new([b.clone(), a.clone()], algorithm()).unwrap();
1732
1733        // Duplicates collapse and input order does not affect the value.
1734        assert_eq!(forward, reverse);
1735        assert_eq!(forward.parents(), &[a.clone(), b.clone()]);
1736        assert!(forward.references_parent(&a));
1737        assert!(!forward.references_parent(&EvidenceId::parse("id-c").unwrap()));
1738    }
1739
1740    #[test]
1741    fn derivation_rejects_more_parents_than_the_bound() {
1742        let parents: Vec<EvidenceId> = (0..=MAX_DERIVATION_PARENTS)
1743            .map(|index| EvidenceId::parse(format!("parent-{index}")).unwrap())
1744            .collect();
1745        assert!(matches!(
1746            EvidenceDerivation::new(parents, algorithm()),
1747            Err(ReasoningModelError::TooManyDerivationParents { .. })
1748        ));
1749    }
1750
1751    #[test]
1752    fn derived_from_sets_lineage_and_participates_in_equality() {
1753        let parent = EvidenceId::parse("body-sample").unwrap();
1754        let derivation = EvidenceDerivation::new([parent.clone()], algorithm()).unwrap();
1755        let direct = evidence();
1756        let derived = evidence().derived_from(derivation.clone());
1757
1758        assert!(direct.origin().is_direct());
1759        assert_eq!(derived.origin().derivation(), Some(&derivation));
1760        assert_eq!(derived.origin().derivation().unwrap().parents(), &[parent]);
1761        // Origin is part of structural identity, so a derived record is never
1762        // equal to an otherwise-identical direct record.
1763        let direct_same_id = Evidence::with_id(
1764            derived.id().clone(),
1765            derived.subject().clone(),
1766            derived.kind().clone(),
1767            derived.predicate().clone(),
1768            derived.value().clone(),
1769            derived.source().clone(),
1770            derived.reliability(),
1771        );
1772        assert_ne!(direct_same_id, derived);
1773    }
1774
1775    #[test]
1776    fn derived_evidence_wire_is_byte_identical_to_direct_and_omits_lineage() {
1777        // The pivotal contract: lineage is runtime-only truth. The serialized
1778        // wire carries no origin field, so a direct and a derived record with
1779        // otherwise-equal fields serialize identically, and a derived record
1780        // round-trips to Direct (lineage is not persisted on this wire).
1781        let parent = EvidenceId::parse("body-sample").unwrap();
1782        let derivation = EvidenceDerivation::new([parent], algorithm()).unwrap();
1783        let base = evidence();
1784        let derived = base.clone().derived_from(derivation);
1785
1786        let direct_wire = serde_json::to_value(&base).unwrap();
1787        let derived_wire = serde_json::to_value(&derived).unwrap();
1788        assert_eq!(direct_wire, derived_wire);
1789        assert!(direct_wire.get("origin").is_none());
1790
1791        let restored: Evidence = serde_json::from_value(derived_wire).unwrap();
1792        assert!(restored.origin().is_direct());
1793    }
1794
1795    #[test]
1796    fn fact_deduplicates_evidence_provenance() {
1797        let evidence = evidence();
1798        let evidence_id = evidence.id().clone();
1799        let mut fact = Fact::new(
1800            evidence.subject().clone(),
1801            evidence.predicate().clone(),
1802            evidence.value().clone(),
1803            ConfidenceScore::from_percent(90).unwrap(),
1804            evidence_id.clone(),
1805        );
1806
1807        fact.add_evidence(evidence_id);
1808
1809        assert_eq!(fact.evidence_ids().len(), 1);
1810        assert_eq!(fact.confidence().basis_points(), 9_000);
1811    }
1812
1813    #[test]
1814    fn hypothesis_uses_bayesian_belief_and_explicit_strength() {
1815        let evidence = evidence();
1816        let mut hypothesis = Hypothesis::new(
1817            evidence.subject().clone(),
1818            evidence.predicate().clone(),
1819            evidence.value().clone(),
1820            Probability::from_percent(10).unwrap(),
1821        );
1822        assert_eq!(hypothesis.strength(), HypothesisStrength::Weak);
1823        assert_eq!(
1824            hypothesis
1825                .observe(
1826                    BayesianEvidence::new(
1827                        evidence.id().clone(),
1828                        Probability::from_percent(80).unwrap(),
1829                        Probability::from_percent(20).unwrap(),
1830                        "framework header observed",
1831                    )
1832                    .unwrap(),
1833                )
1834                .unwrap(),
1835            BeliefWrite::Inserted
1836        );
1837        assert_eq!(hypothesis.posterior().parts_per_million(), 307_692);
1838        hypothesis.set_strength(HypothesisStrength::Strong);
1839        hypothesis.set_state(HypothesisState::Supported);
1840
1841        assert_eq!(hypothesis.belief().evidence().len(), 1);
1842        assert_eq!(hypothesis.belief().updates().len(), 1);
1843        assert_eq!(hypothesis.strength(), HypothesisStrength::Strong);
1844        assert_eq!(hypothesis.state(), HypothesisState::Supported);
1845    }
1846
1847    #[test]
1848    fn hypothesis_stable_identity_ignores_wall_clock_for_idempotency() {
1849        let evidence = evidence();
1850        let first = Hypothesis::with_id(
1851            "rule:laravel:endpoint",
1852            evidence.subject().clone(),
1853            evidence.predicate().clone(),
1854            evidence.value().clone(),
1855            Probability::from_percent(10).unwrap(),
1856        )
1857        .unwrap();
1858        let second = Hypothesis::with_id(
1859            "rule:laravel:endpoint",
1860            evidence.subject().clone(),
1861            evidence.predicate().clone(),
1862            evidence.value().clone(),
1863            Probability::from_percent(10).unwrap(),
1864        )
1865        .unwrap();
1866
1867        assert!(first.same_evaluation_as(&second));
1868        assert!(Hypothesis::with_id(
1869            " ",
1870            evidence.subject().clone(),
1871            evidence.predicate().clone(),
1872            evidence.value().clone(),
1873            Probability::from_percent(10).unwrap(),
1874        )
1875        .is_err());
1876    }
1877
1878    #[test]
1879    fn bayesian_belief_is_idempotent_and_order_independent() {
1880        let first = BayesianEvidence::new(
1881            EvidenceId::parse("evidence:a").unwrap(),
1882            Probability::from_percent(80).unwrap(),
1883            Probability::from_percent(20).unwrap(),
1884            "specific cookie",
1885        )
1886        .unwrap();
1887        let second = BayesianEvidence::new(
1888            EvidenceId::parse("evidence:b").unwrap(),
1889            Probability::from_percent(70).unwrap(),
1890            Probability::from_percent(30).unwrap(),
1891            "framework header",
1892        )
1893        .unwrap();
1894        let mut forward = BayesianBelief::new(Probability::from_percent(10).unwrap());
1895        let mut reverse = BayesianBelief::new(Probability::from_percent(10).unwrap());
1896
1897        assert_eq!(
1898            forward.observe(first.clone()).unwrap(),
1899            BeliefWrite::Inserted
1900        );
1901        assert_eq!(
1902            forward.observe(second.clone()).unwrap(),
1903            BeliefWrite::Inserted
1904        );
1905        assert_eq!(
1906            forward.observe(first.clone()).unwrap(),
1907            BeliefWrite::Unchanged
1908        );
1909        reverse.observe(second).unwrap();
1910        reverse.observe(first).unwrap();
1911
1912        assert_eq!(forward, reverse);
1913        assert_eq!(forward.evidence().len(), 2);
1914        assert_eq!(forward.updates()[0].evidence_id().as_str(), "evidence:a");
1915        assert_eq!(forward.updates()[1].evidence_id().as_str(), "evidence:b");
1916    }
1917
1918    #[test]
1919    fn bayesian_belief_replaces_likelihoods_transactionally() {
1920        let evidence_id = EvidenceId::parse("evidence:cookie").unwrap();
1921        let mut belief = BayesianBelief::new(Probability::from_percent(10).unwrap());
1922        belief
1923            .observe(
1924                BayesianEvidence::new(
1925                    evidence_id.clone(),
1926                    Probability::from_percent(80).unwrap(),
1927                    Probability::from_percent(20).unwrap(),
1928                    "initial calibration",
1929                )
1930                .unwrap(),
1931            )
1932            .unwrap();
1933        let original = belief.clone();
1934
1935        assert_eq!(
1936            belief
1937                .observe(
1938                    BayesianEvidence::new(
1939                        evidence_id.clone(),
1940                        Probability::from_percent(90).unwrap(),
1941                        Probability::from_percent(10).unwrap(),
1942                        "recalibrated",
1943                    )
1944                    .unwrap(),
1945                )
1946                .unwrap(),
1947            BeliefWrite::Replaced
1948        );
1949        assert_eq!(belief.evidence().len(), 1);
1950        assert_ne!(belief.posterior(), original.posterior());
1951
1952        let before_invalid_update = belief.clone();
1953        assert_eq!(
1954            belief.observe(
1955                BayesianEvidence::new(
1956                    evidence_id,
1957                    Probability::ZERO,
1958                    Probability::ZERO,
1959                    "invalid calibration",
1960                )
1961                .unwrap()
1962            ),
1963            Err(ReasoningModelError::UndefinedPosterior)
1964        );
1965        assert_eq!(belief, before_invalid_update);
1966    }
1967
1968    #[test]
1969    fn bayesian_belief_wire_format_recomputes_posterior() {
1970        let evidence = evidence();
1971        let mut belief = BayesianBelief::new(Probability::from_percent(10).unwrap());
1972        belief
1973            .observe(
1974                BayesianEvidence::new(
1975                    evidence.id().clone(),
1976                    Probability::from_percent(80).unwrap(),
1977                    Probability::from_percent(20).unwrap(),
1978                    "framework header observed",
1979                )
1980                .unwrap(),
1981            )
1982            .unwrap();
1983        let encoded = serde_json::to_value(&belief).unwrap();
1984        let decoded: BayesianBelief = serde_json::from_value(encoded.clone()).unwrap();
1985        assert_eq!(decoded, belief);
1986
1987        let mut tampered = encoded;
1988        tampered["posterior"] = serde_json::json!(500_000);
1989        assert!(serde_json::from_value::<BayesianBelief>(tampered).is_err());
1990
1991        let mut duplicated = serde_json::to_value(&belief).unwrap();
1992        let first_observation = duplicated["evidence"][0].clone();
1993        duplicated["evidence"]
1994            .as_array_mut()
1995            .unwrap()
1996            .push(first_observation);
1997        assert!(serde_json::from_value::<BayesianBelief>(duplicated).is_err());
1998    }
1999
2000    #[test]
2001    fn relation_deduplicates_evidence_provenance() {
2002        let evidence = evidence();
2003        let evidence_id = evidence.id().clone();
2004        let mut relation = KnowledgeRelation::new(
2005            EntityId::new("technology:php").unwrap(),
2006            EntityId::new("technology:laravel").unwrap(),
2007            RelationKind::Uses,
2008            ConfidenceScore::from_percent(82).unwrap(),
2009            evidence_id.clone(),
2010        );
2011
2012        relation.add_evidence(evidence_id);
2013
2014        assert_eq!(relation.evidence_ids().len(), 1);
2015        assert_eq!(relation.confidence().basis_points(), 8_200);
2016    }
2017
2018    #[test]
2019    fn required_names_reject_whitespace() {
2020        assert!(EntityId::new("   ").is_err());
2021        assert!(KnowledgePredicate::new("http", " ").is_err());
2022        assert!(EvidenceSource::new("", "header").is_err());
2023        assert!(EvidenceSource::new("http", "header")
2024            .unwrap()
2025            .with_correlation_id(" ")
2026            .is_err());
2027        assert!(KnowledgeEntity::new(subject(), EntityKind::Endpoint, " ").is_err());
2028    }
2029
2030    #[test]
2031    fn wire_format_cannot_bypass_non_empty_invariants() {
2032        let evidence = evidence();
2033        let mut fact = Fact::new(
2034            evidence.subject().clone(),
2035            evidence.predicate().clone(),
2036            evidence.value().clone(),
2037            ConfidenceScore::from_percent(90).unwrap(),
2038            evidence.id().clone(),
2039        );
2040        fact.evidence_ids.clear();
2041        assert!(serde_json::from_value::<Fact>(serde_json::to_value(fact).unwrap()).is_err());
2042
2043        let invalid_entity = serde_json::json!({
2044            "id": "endpoint:test",
2045            "kind": "endpoint",
2046            "label": " "
2047        });
2048        assert!(serde_json::from_value::<KnowledgeEntity>(invalid_entity).is_err());
2049
2050        let invalid_contribution = serde_json::json!({
2051            "evidence_id": evidence.id(),
2052            "direction": "supporting",
2053            "weight": 5_000,
2054            "rationale": " "
2055        });
2056        assert!(serde_json::from_value::<EvidenceContribution>(invalid_contribution).is_err());
2057    }
2058}