Skip to main content

remem/context_bundle/
executor.rs

1//! Deterministic v1 executor: applies a [`RetrievalPlan`] to caller-provided
2//! candidates by reusing the SessionStart relevance selector, enforces
3//! section and total token budgets, and emits an audited [`ContextBundle`].
4//!
5//! v1 has no DB access; wiring the executor to the SessionStart loaders is
6//! follow-up work on GH-932.
7
8use std::collections::HashMap;
9
10use crate::context::{
11    build_sessionstart_relevance_plan, RelevanceCandidate, RelevanceSection,
12    SessionStartRelevancePlan,
13};
14
15use super::audit::AuditBuilder;
16use crate::retrieval_router::{AbstentionMode, RetrievalPlan};
17
18use super::domain::{
19    ChannelKind, ContextBundle, ContextItem, DegradedMode, ItemValidity, SourceKind, TrustClass,
20    CONTEXT_BUNDLE_SCHEMA_VERSION,
21};
22use super::policy::{
23    estimate_item_tokens, validate_plan, REASON_BELOW_TRUST_FLOOR, REASON_BRANCH_SCOPE_MISMATCH,
24    REASON_CANONICAL_LOAD_FAILED, REASON_CANONICAL_ONLY_DEGRADED, REASON_CHANNEL_ITEM_LIMIT,
25    REASON_CHANNEL_TOKEN_BUDGET, REASON_PLAN_BLOCKED, REASON_POISONING_GATE,
26    REASON_PROJECT_SCOPE_MISMATCH, REASON_QUARANTINED_TRUST, REASON_SELECTED_CHANNEL,
27    REASON_SELECTED_RELEVANCE, REASON_SUPERSEDED_EXCLUDED, REASON_TOTAL_TOKEN_BUDGET,
28};
29
30/// Candidate inputs for one execution. `enrichment_available = false`
31/// degrades the bundle to `canonical_only`: generated and graph-derived
32/// candidates are dropped instead of being served without their backing
33/// enrichment stack.
34#[derive(Debug, Clone)]
35pub struct ExecutorInputs {
36    pub candidates: Vec<ContextItem>,
37    /// Candidates rejected by the canonical loader's poisoning gate. Their
38    /// title/text never enter the returned bundle, but their redacted identity
39    /// remains in the audit so the endpoint accounts for every loaded row.
40    pub poisoning_drops: Vec<ContextItem>,
41    /// Safe canonical rows intentionally omitted by an upstream canonical
42    /// selector. These are audit-only and never enter a returned section.
43    pub preselection_drops: Vec<PreselectionDrop>,
44    pub enrichment_available: bool,
45}
46
47#[derive(Debug, Clone)]
48pub struct PreselectionDrop {
49    pub item: ContextItem,
50    pub reason: String,
51}
52
53/// SessionStart's compatibility renderer owns exact character and item
54/// boundaries. The generic executor keeps strict token enforcement, while the
55/// renderer integration may defer those final budget decisions and seal the
56/// bundle after byte-compatible rendering.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(crate) enum BudgetEnforcement {
59    Strict,
60    DeferToRenderer,
61}
62
63pub(crate) struct ExecutionTrace {
64    pub bundle: ContextBundle,
65    pub relevance_plan: SessionStartRelevancePlan,
66}
67
68/// Execute a plan over the provided candidates.
69///
70/// Deterministic: same plan + same inputs always produce the same bundle.
71/// An invalid plan (schema/policy/scope) produces a `blocked` bundle whose
72/// audit drops every candidate; it never partially executes.
73pub fn execute(plan: &RetrievalPlan, inputs: &ExecutorInputs) -> ContextBundle {
74    execute_with_trace(plan, inputs, BudgetEnforcement::Strict).bundle
75}
76
77pub(crate) fn execute_with_trace(
78    plan: &RetrievalPlan,
79    inputs: &ExecutorInputs,
80    budget_enforcement: BudgetEnforcement,
81) -> ExecutionTrace {
82    if let Err(error) = validate_plan(plan) {
83        return ExecutionTrace {
84            bundle: blocked_bundle(plan, inputs, &error.to_string()),
85            relevance_plan: SessionStartRelevancePlan::disabled(&[]),
86        };
87    }
88    let degraded_mode = if inputs.enrichment_available {
89        DegradedMode::Full
90    } else {
91        DegradedMode::CanonicalOnly
92    };
93
94    let mut audit = AuditBuilder::default();
95    for item in &inputs.poisoning_drops {
96        audit.dropped(item, REASON_POISONING_GATE);
97    }
98    for dropped in &inputs.preselection_drops {
99        audit.dropped(&dropped.item, &dropped.reason);
100    }
101    let mut in_scope: Vec<&ContextItem> = Vec::new();
102    for item in &inputs.candidates {
103        match scope_drop_reason(plan, degraded_mode, item) {
104            Some(reason) => audit.dropped(item, reason),
105            None => in_scope.push(item),
106        }
107    }
108
109    let (relevance, relevance_plan) = relevance_decisions(plan, &in_scope, &mut audit);
110    let mut survivors: Vec<&ContextItem> = Vec::new();
111    for item in in_scope {
112        let governed = channel_relevance_governed(plan, item.channel);
113        if governed {
114            match relevance.get(item.stable_key.as_str()) {
115                Some(&(true, _)) | None => survivors.push(item),
116                Some(&(false, drop_reason)) => audit.dropped(item, drop_reason),
117            }
118        } else {
119            survivors.push(item);
120        }
121    }
122
123    let mut bundle = empty_bundle(plan, degraded_mode);
124    match budget_enforcement {
125        BudgetEnforcement::Strict => {
126            order_relevance_governed_survivors(plan, &relevance_plan, &mut survivors);
127            apply_budgets(plan, degraded_mode, &survivors, &mut bundle, &mut audit)
128        }
129        BudgetEnforcement::DeferToRenderer => {
130            select_for_renderer(plan, &survivors, &mut bundle, &mut audit)
131        }
132    }
133    if plan.abstention_policy.mode == AbstentionMode::OnLowEvidence
134        && audit.selected_count() < plan.abstention_policy.min_selected_items
135    {
136        clear_bundle_sections(&mut bundle);
137        audit.abstain();
138    }
139    bundle.audit = audit.finalize(plan, degraded_mode);
140    ExecutionTrace {
141        bundle,
142        relevance_plan,
143    }
144}
145
146/// Section limits must consume relevance-selected rows in relevance order,
147/// not in the canonical loader's incidental row order. The stable sort keeps
148/// non-governed channels and disabled relevance plans byte-for-byte unchanged.
149fn order_relevance_governed_survivors(
150    plan: &RetrievalPlan,
151    relevance_plan: &SessionStartRelevancePlan,
152    survivors: &mut [&ContextItem],
153) {
154    let ranks = relevance_plan
155        .selected_keys()
156        .iter()
157        .enumerate()
158        .map(|(rank, key)| (key.as_str(), rank))
159        .collect::<HashMap<_, _>>();
160    survivors.sort_by_key(|item| {
161        if channel_relevance_governed(plan, item.channel) {
162            ranks
163                .get(item.stable_key.as_str())
164                .copied()
165                .unwrap_or(usize::MAX)
166        } else {
167            usize::MAX
168        }
169    });
170}
171
172fn scope_drop_reason(
173    plan: &RetrievalPlan,
174    degraded_mode: DegradedMode,
175    item: &ContextItem,
176) -> Option<&'static str> {
177    if item.trust == TrustClass::Quarantined {
178        return Some(REASON_QUARANTINED_TRUST);
179    }
180    if trust_rank(item.trust) < trust_rank(plan.trust_policy.minimum_trust) {
181        return Some(REASON_BELOW_TRUST_FLOOR);
182    }
183    if let Some(project) = &item.project {
184        if project != &plan.filters.project {
185            return Some(REASON_PROJECT_SCOPE_MISMATCH);
186        }
187    }
188    if let (Some(item_branch), Some(plan_branch)) = (&item.branch, &plan.filters.branch) {
189        if item_branch != plan_branch {
190            return Some(REASON_BRANCH_SCOPE_MISMATCH);
191        }
192    }
193    if item.validity == ItemValidity::Superseded && !plan.filters.include_superseded {
194        return Some(REASON_SUPERSEDED_EXCLUDED);
195    }
196    if degraded_mode == DegradedMode::CanonicalOnly && item.source_kind != SourceKind::Canonical {
197        return Some(REASON_CANONICAL_ONLY_DEGRADED);
198    }
199    None
200}
201
202fn trust_rank(trust: TrustClass) -> u8 {
203    match trust {
204        TrustClass::Quarantined => 0,
205        TrustClass::Standard => 1,
206        TrustClass::Trusted => 2,
207    }
208}
209
210fn clear_bundle_sections(bundle: &mut ContextBundle) {
211    bundle.preferences.clear();
212    bundle.failure_lessons.clear();
213    bundle.current_truth.clear();
214    bundle.workstreams.clear();
215    bundle.memory_index.clear();
216    bundle.recent_sessions.clear();
217}
218
219fn channel_relevance_governed(plan: &RetrievalPlan, channel: ChannelKind) -> bool {
220    plan.output_sections
221        .iter()
222        .find(|planned| planned.channel == channel)
223        .is_some_and(|planned| planned.relevance_governed)
224}
225
226fn relevance_section(channel: ChannelKind) -> Option<RelevanceSection> {
227    match channel {
228        ChannelKind::Lessons => Some(RelevanceSection::Lessons),
229        ChannelKind::MemoryIndex => Some(RelevanceSection::MemoryIndex),
230        ChannelKind::Sessions => Some(RelevanceSection::Sessions),
231        ChannelKind::Preferences | ChannelKind::Core | ChannelKind::Workstreams => None,
232    }
233}
234
235/// Reuse the SessionStart relevance selector for the governed channels.
236/// Returns `stable_key -> (selected, drop_reason)`; drop reasons are the
237/// SessionStart reason strings.
238fn relevance_decisions<'a>(
239    plan: &RetrievalPlan,
240    in_scope: &[&'a ContextItem],
241    audit: &mut AuditBuilder,
242) -> (
243    HashMap<&'a str, (bool, &'static str)>,
244    SessionStartRelevancePlan,
245) {
246    let candidates: Vec<RelevanceCandidate> = in_scope
247        .iter()
248        .filter(|item| channel_relevance_governed(plan, item.channel))
249        .filter_map(|item| {
250            relevance_section(item.channel).map(|section| RelevanceCandidate {
251                stable_key: item.stable_key.clone(),
252                section,
253                text: format!("{} {}", item.title, item.text),
254            })
255        })
256        .collect();
257    let relevance_plan = build_sessionstart_relevance_plan(
258        plan.relevance_query.as_deref(),
259        plan.relevance_k as usize,
260        &candidates,
261    );
262    let mut decisions = HashMap::new();
263    for item in in_scope {
264        let Some(decision) = relevance_plan.decision(&item.stable_key) else {
265            continue;
266        };
267        audit.record_score(&item.stable_key, decision.score);
268        decisions.insert(
269            item.stable_key.as_str(),
270            (decision.selected, decision.drop_reason.unwrap_or("dropped")),
271        );
272    }
273    (decisions, relevance_plan)
274}
275
276/// Preserve scope/relevance decisions in the bundle, but leave exact item,
277/// section-character, and total-character enforcement to the established
278/// SessionStart renderer. The render path seals the returned bundle to the
279/// identities that survived those exact boundaries before it can be exposed
280/// to any downstream consumer.
281fn select_for_renderer(
282    plan: &RetrievalPlan,
283    survivors: &[&ContextItem],
284    bundle: &mut ContextBundle,
285    audit: &mut AuditBuilder,
286) {
287    for item in survivors {
288        let governed = channel_relevance_governed(plan, item.channel);
289        let reason = if governed {
290            REASON_SELECTED_RELEVANCE
291        } else {
292            REASON_SELECTED_CHANNEL
293        };
294        audit.selected(item, reason);
295        bundle.section_mut(item.channel).push((*item).clone());
296    }
297}
298
299/// Enforce per-channel item limits, per-channel token budgets, and the
300/// total token budget in the fixed [`ChannelKind::ORDERED`] order.
301fn apply_budgets(
302    plan: &RetrievalPlan,
303    _degraded_mode: DegradedMode,
304    survivors: &[&ContextItem],
305    bundle: &mut ContextBundle,
306    audit: &mut AuditBuilder,
307) {
308    let mut total_tokens: u32 = 0;
309    let total_budget = plan.section_budgets.total_tokens;
310    for channel in ChannelKind::ORDERED {
311        let item_limit = plan
312            .output_sections
313            .iter()
314            .find(|planned| planned.channel == channel)
315            .map(|planned| planned.item_limit)
316            .unwrap_or(0);
317        let channel_budget = plan.section_budgets.for_channel(channel);
318        let governed = channel_relevance_governed(plan, channel);
319        let mut channel_tokens: u32 = 0;
320        let mut channel_count: u32 = 0;
321        for item in survivors.iter().filter(|item| item.channel == channel) {
322            let tokens = estimate_item_tokens(item);
323            if channel_count >= item_limit {
324                audit.dropped(item, REASON_CHANNEL_ITEM_LIMIT);
325                continue;
326            }
327            if channel_tokens + tokens > channel_budget {
328                audit.dropped(item, REASON_CHANNEL_TOKEN_BUDGET);
329                continue;
330            }
331            if total_tokens + tokens > total_budget {
332                audit.dropped(item, REASON_TOTAL_TOKEN_BUDGET);
333                audit.set_truncation_reason(REASON_TOTAL_TOKEN_BUDGET);
334                continue;
335            }
336            channel_count += 1;
337            channel_tokens += tokens;
338            total_tokens += tokens;
339            let reason = if governed {
340                REASON_SELECTED_RELEVANCE
341            } else {
342                REASON_SELECTED_CHANNEL
343            };
344            audit.selected(item, reason);
345            bundle.section_mut(channel).push((*item).clone());
346        }
347    }
348}
349
350fn empty_bundle(plan: &RetrievalPlan, degraded_mode: DegradedMode) -> ContextBundle {
351    ContextBundle {
352        schema_version: CONTEXT_BUNDLE_SCHEMA_VERSION,
353        plan_hash: plan.plan_hash.clone(),
354        degraded_mode,
355        preferences: Vec::new(),
356        failure_lessons: Vec::new(),
357        current_truth: Vec::new(),
358        workstreams: Vec::new(),
359        memory_index: Vec::new(),
360        recent_sessions: Vec::new(),
361        audit: super::domain::ContextAudit {
362            schema_version: CONTEXT_BUNDLE_SCHEMA_VERSION,
363            policy_version: plan.policy_version.clone(),
364            relevance_policy_version: plan.relevance_policy_version.clone(),
365            plan_hash: plan.plan_hash.clone(),
366            degraded_mode,
367            candidates_considered: 0,
368            selected_count: 0,
369            dropped_count: 0,
370            token_estimate: 0,
371            token_budget: plan.section_budgets.total_tokens,
372            truncation_reason: None,
373            entries: Vec::new(),
374            shadow_comparison: Vec::new(),
375        },
376    }
377}
378
379fn blocked_bundle(plan: &RetrievalPlan, inputs: &ExecutorInputs, error: &str) -> ContextBundle {
380    crate::log::error(
381        "context-bundle",
382        &format!("plan validation failed; emitting blocked bundle: {error}"),
383    );
384    let mut audit = AuditBuilder::default();
385    for item in &inputs.candidates {
386        audit.dropped(item, REASON_PLAN_BLOCKED);
387    }
388    for item in &inputs.poisoning_drops {
389        audit.dropped(item, REASON_POISONING_GATE);
390    }
391    for dropped in &inputs.preselection_drops {
392        audit.dropped(&dropped.item, &dropped.reason);
393    }
394    audit.set_truncation_reason(REASON_PLAN_BLOCKED);
395    let mut bundle = empty_bundle(plan, DegradedMode::Blocked);
396    bundle.audit = audit.finalize(plan, DegradedMode::Blocked);
397    bundle
398}
399
400/// A `Blocked` bundle for a failure that happened before any candidate
401/// existed — a canonical load error, most importantly.
402///
403/// The bundle is empty and its audit records `reason` as the truncation
404/// reason, so a caller cannot mistake "canonical data could not be read"
405/// for "this project has no memory".
406pub fn blocked_before_load(plan: &RetrievalPlan, reason: &str) -> ContextBundle {
407    crate::log::error(
408        "context-bundle",
409        &format!("canonical load failed; emitting blocked bundle: {reason}"),
410    );
411    let mut audit = AuditBuilder::default();
412    audit.set_truncation_reason(REASON_CANONICAL_LOAD_FAILED);
413    let mut bundle = empty_bundle(plan, DegradedMode::Blocked);
414    bundle.audit = audit.finalize(plan, DegradedMode::Blocked);
415    bundle
416}