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}