Skip to main content

areev_context/
policy.rs

1//! Format policy types for context rendering.
2//!
3//! `FormatPolicy` controls how recalled grains are rendered into LLM-ready strings:
4//! output format (SML/Markdown/PlainText/JSON), metadata verbosity, ordering,
5//! token budget, and section grouping.
6
7use areev_cal::store_types::GrainTypeDiversityConfig;
8use areev_core::types::GrainType;
9use std::collections::HashMap;
10
11/// Output format for rendered context.
12#[derive(Debug, Clone, PartialEq)]
13pub enum OutputFormat {
14    /// SML tags: `<fact>`, `<event>`, etc. Optimized for Claude.
15    Sml,
16    /// TOON (Token-Oriented Object Notation). Compact, indentation-based format optimized for LLM token efficiency.
17    Toon,
18    /// Markdown with headers and formatting. Good for GPT-4/Gemini.
19    Markdown,
20    /// Plain text with `===` section headers.
21    PlainText,
22    /// Structured JSON. For programmatic consumers and A2A.
23    Json,
24}
25
26/// How much metadata to include per grain.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum MetadataLevel {
29    /// Content fields only — no hash, no confidence, no timestamps.
30    None,
31    /// Confidence + created_at only.
32    Minimal,
33    /// All common metadata: hash, confidence, tags, source_type, created_at,
34    /// verification_status, namespace, author_did.
35    Full,
36}
37
38/// Ordering strategy for grains in output.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum Ordering {
41    /// By retrieval score (highest first). Default.
42    ByRelevance,
43    /// Oldest first (ascending created_at).
44    Chronological,
45    /// Newest first (descending created_at).
46    ReverseChronological,
47    /// Group by entity (subject field), within each group order by relevance.
48    ByEntity,
49}
50
51/// Section grouping configuration.
52#[derive(Debug, Clone, Default)]
53pub struct SectionConfig {
54    /// Whether to group grains by type with section headers.
55    pub group_by_type: bool,
56    /// Custom section order. When empty, uses default:
57    /// State, Goal, Fact, Tool, Event, Observation, Reasoning,
58    /// Workflow, Consensus, Consent.
59    pub type_order: Vec<GrainType>,
60}
61
62/// Per-grain-type overrides.
63#[derive(Debug, Clone)]
64pub struct GrainTypeOverride {
65    /// Whether to include this grain type. Default: true.
66    pub include: bool,
67    /// Max grains of this type. None = no limit (budget-constrained).
68    pub max_count: Option<usize>,
69}
70
71impl Default for GrainTypeOverride {
72    fn default() -> Self {
73        Self {
74            include: true,
75            max_count: None,
76        }
77    }
78}
79
80/// How a decision backend installed with
81/// [`ContextAssembler::with_decider`](crate::ContextAssembler::with_decider)
82/// may shape assembly (decision-backend phase 3, rows A2/A3). With no backend
83/// installed this is inert; with one installed and `FormatPolicy.decide`
84/// left `None`, [`DecidePolicy::default`] applies.
85///
86/// Rule 2 of `docs/decision-model-proposal.md` binds the thresholds: they act
87/// only when the backend's `Decision.calibrated` is true. An uncalibrated
88/// backend's relevance reorders the allocation and nothing else.
89#[derive(Debug, Clone, PartialEq)]
90pub struct DecidePolicy {
91    /// A3: ask whether the query wants a timeline, the current value, or
92    /// neither, in place of the keyword lists. Default true.
93    pub intent: bool,
94    /// A2: ask per candidate how relevant it is and whether a summary would
95    /// lose a needed detail, and feed both to the allocator. Default true.
96    pub disclosure: bool,
97    /// Calibrated only: relevance (`score / 3`) below this is Omitted
98    /// regardless of budget. Default 0.10.
99    pub drop_below: f32,
100    /// Calibrated only: `p(summary loses a needed detail)` at or above this
101    /// prefers the Full render — the budget's 95% line still bounds it.
102    /// Default 0.50.
103    pub full_above: f32,
104    /// Deadline for ALL of one assembly's backend calls together, in
105    /// milliseconds. `None` = the backend's (chain's) own default.
106    pub deadline_ms: Option<u64>,
107}
108
109impl Default for DecidePolicy {
110    fn default() -> Self {
111        Self {
112            intent: true,
113            disclosure: true,
114            drop_below: areev_cal::judge::DEFAULT_DROP_BELOW,
115            full_above: areev_cal::judge::DEFAULT_FULL_ABOVE,
116            deadline_ms: None,
117        }
118    }
119}
120
121/// Complete formatting policy. Constructed via builder pattern.
122#[derive(Debug, Clone)]
123pub struct FormatPolicy {
124    pub format: OutputFormat,
125    pub metadata: MetadataLevel,
126    pub ordering: Ordering,
127    pub sections: SectionConfig,
128    pub token_budget: Option<usize>,
129    pub grain_overrides: HashMap<GrainType, GrainTypeOverride>,
130    /// Original query text. Used by Knowledge Update chain rendering to detect
131    /// recency intent ("currently", "most recently", etc.) for update-only
132    /// suppression mode.
133    pub query_text: Option<String>,
134    /// Grain type diversity floor. When `Some`, the budget allocator reserves
135    /// slots for underrepresented grain types before filling by priority.
136    /// Default: `Some(GrainTypeDiversityConfig::default())`.
137    pub grain_type_diversity: Option<GrainTypeDiversityConfig>,
138    /// Admit grains whose `verification_status` is `retracted`. Off by
139    /// default: a retraction withholds a memory from being treated as true,
140    /// so assembly must not hand one to a model. Turn it on for audit and
141    /// forensic reads, which want the withdrawn record precisely because it
142    /// was withdrawn.
143    pub include_retracted: bool,
144    /// How an installed decision backend may shape this assembly. `None`
145    /// with a backend installed = [`DecidePolicy::default`]; with no backend
146    /// installed this field is never read.
147    pub decide: Option<DecidePolicy>,
148}
149
150impl FormatPolicy {
151    pub fn new(format: OutputFormat) -> Self {
152        Self {
153            format,
154            metadata: MetadataLevel::Minimal,
155            ordering: Ordering::ByRelevance,
156            sections: SectionConfig::default(),
157            token_budget: None,
158            grain_overrides: HashMap::new(),
159            query_text: None,
160            grain_type_diversity: Some(GrainTypeDiversityConfig::default()),
161            include_retracted: false,
162            decide: None,
163        }
164    }
165
166    /// Set how an installed decision backend may shape this assembly.
167    pub fn decide(mut self, policy: DecidePolicy) -> Self {
168        self.decide = Some(policy);
169        self
170    }
171
172    pub fn include_retracted(mut self, on: bool) -> Self {
173        self.include_retracted = on;
174        self
175    }
176
177    pub fn metadata(mut self, level: MetadataLevel) -> Self {
178        self.metadata = level;
179        self
180    }
181
182    pub fn ordering(mut self, ordering: Ordering) -> Self {
183        self.ordering = ordering;
184        self
185    }
186
187    pub fn token_budget(mut self, budget: usize) -> Self {
188        self.token_budget = Some(budget);
189        self
190    }
191
192    pub fn group_by_type(mut self) -> Self {
193        self.sections.group_by_type = true;
194        self
195    }
196
197    pub fn grain_override(mut self, gt: GrainType, ovr: GrainTypeOverride) -> Self {
198        self.grain_overrides.insert(gt, ovr);
199        self
200    }
201
202    /// Set the original query text for recency detection in KU rendering.
203    pub fn query_text(mut self, text: impl Into<String>) -> Self {
204        self.query_text = Some(text.into());
205        self
206    }
207
208    /// Set grain type diversity configuration.
209    pub fn grain_type_diversity(mut self, config: GrainTypeDiversityConfig) -> Self {
210        self.grain_type_diversity = Some(config);
211        self
212    }
213
214    /// Disable grain type diversity floor (pure priority allocation).
215    pub fn no_grain_type_diversity(mut self) -> Self {
216        self.grain_type_diversity = None;
217        self
218    }
219}
220
221impl Default for FormatPolicy {
222    fn default() -> Self {
223        Self::new(OutputFormat::PlainText)
224            .metadata(MetadataLevel::Minimal)
225            .ordering(Ordering::ByRelevance)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_default_policy() {
235        let p = FormatPolicy::default();
236        assert_eq!(p.format, OutputFormat::PlainText);
237        assert_eq!(p.metadata, MetadataLevel::Minimal);
238        assert_eq!(p.ordering, Ordering::ByRelevance);
239        assert!(p.token_budget.is_none());
240        assert!(!p.sections.group_by_type);
241    }
242
243    #[test]
244    fn test_builder_chain() {
245        let p = FormatPolicy::new(OutputFormat::Sml)
246            .metadata(MetadataLevel::Full)
247            .ordering(Ordering::Chronological)
248            .token_budget(4096)
249            .group_by_type()
250            .grain_override(
251                GrainType::Consent,
252                GrainTypeOverride {
253                    include: true,
254                    max_count: Some(5),
255                },
256            );
257
258        assert_eq!(p.format, OutputFormat::Sml);
259        assert_eq!(p.metadata, MetadataLevel::Full);
260        assert_eq!(p.ordering, Ordering::Chronological);
261        assert_eq!(p.token_budget, Some(4096));
262        assert!(p.sections.group_by_type);
263        assert_eq!(
264            p.grain_overrides
265                .get(&GrainType::Consent)
266                .unwrap()
267                .max_count,
268            Some(5)
269        );
270    }
271}