Skip to main content

ratel_ai_core/
fact.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::str::FromStr;
4
5/// Whether a [`Fact`] is *always* injected or only surfaced *when a query
6/// retrieves it* — the one bit that splits the always-on tier from the
7/// retrieval-gated tier.
8///
9/// The core does not inject anything itself; `pin` is metadata the higher
10/// layers (the SDK grounding path) act on. [`crate::FactRegistry::pinned`]
11/// filters the corpus by it, and both variants are ranked by
12/// [`crate::FactRegistry::search`] all the same, so a pinned fact is still
13/// discoverable by a query.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum PinMode {
16    /// Always injected into the context, no ranking — the barbershop's address,
17    /// hours, brand voice. Kept tiny; it is paid for on every injection.
18    Always,
19    /// Injected only when a query ranks it in — pricing tables, per-service
20    /// policies. The default: a new fact is retrieval-gated until promoted.
21    #[default]
22    Retrieved,
23}
24
25impl PinMode {
26    /// The wire/`as_str` identifier used across the SDKs: `"always"` or
27    /// `"retrieved"`.
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            PinMode::Always => "always",
31            PinMode::Retrieved => "retrieved",
32        }
33    }
34}
35
36impl fmt::Display for PinMode {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(self.as_str())
39    }
40}
41
42/// The identifier did not name a known pin mode.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ParsePinModeError(pub String);
45
46impl fmt::Display for ParsePinModeError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(
49            f,
50            "unknown pin mode {:?} (expected \"always\" or \"retrieved\")",
51            self.0
52        )
53    }
54}
55
56impl std::error::Error for ParsePinModeError {}
57
58impl FromStr for PinMode {
59    type Err = ParsePinModeError;
60
61    /// Parse the SDK identifier: `"always"` or `"retrieved"`.
62    ///
63    /// # Errors
64    ///
65    /// Any other string is a [`ParsePinModeError`] naming the rejected input.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use ratel_ai_core::PinMode;
71    ///
72    /// assert_eq!("always".parse::<PinMode>(), Ok(PinMode::Always));
73    /// assert_eq!("retrieved".parse::<PinMode>(), Ok(PinMode::Retrieved));
74    /// assert!("pinned".parse::<PinMode>().is_err());
75    /// ```
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        match s {
78            "always" => Ok(PinMode::Always),
79            "retrieved" => Ok(PinMode::Retrieved),
80            other => Err(ParsePinModeError(other.to_string())),
81        }
82    }
83}
84
85/// A fact registered for grounding — constant, declarative context an agent
86/// needs to have on hand (a barbershop's address and hours, a brand's voice).
87/// The push-path analog of a [`crate::Skill`]: where a skill is a playbook the
88/// agent *pulls* and runs on demand, a fact is content the grounding layer
89/// *pushes* into the context so the model is never missing it.
90///
91/// `name`, the effective searchable description, and `tags` drive ranking
92/// exactly as on a [`crate::Skill`]. The description component defaults to
93/// [`Self::description`] and can be replaced by
94/// [`Self::experimental_searchable_description`], so the retrieval-gated tier is discoverable
95/// by query. `body` is the injected content — **not**
96/// indexed, so a long body never skews relevance. `pin` splits the tiers (see
97/// [`PinMode`]); `metadata` is free-form, non-indexed context for higher-layer
98/// biasing, never matched as query terms.
99pub struct Fact {
100    /// Stable identifier, returned in [`crate::FactHit::fact_id`] and stamped
101    /// on the grounding trace events. Registering the same id again replaces
102    /// the entry in place. Not indexed for ranking.
103    pub id: String,
104    /// Short name. Indexed both verbatim and identifier-split, so
105    /// snake_case/camelCase/kebab constituent words match.
106    pub name: String,
107    /// What the fact is about — the primary ranking text (not the content
108    /// itself; that is `body`).
109    pub description: String,
110    /// Experimental replacement for the description component used by BM25 and
111    /// dense retrieval. The fact name and tags remain indexed. `None` uses
112    /// [`Self::description`].
113    pub experimental_searchable_description: Option<String>,
114    /// Author-declared labels and task phrases, indexed alongside the
115    /// description.
116    pub tags: Vec<String>,
117    /// Free-form, non-indexed context for higher layers (push-path
118    /// boosting/filtering); never matched as query terms.
119    pub metadata: HashMap<String, Vec<String>>,
120    /// The fact's content — the payload injected into the context, not indexed
121    /// (a long body would otherwise drown the description's term weights).
122    pub body: String,
123    /// Always-on vs retrieval-gated (see [`PinMode`]). Not indexed.
124    pub pin: PinMode,
125}