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}