Skip to main content

brink_runtime/
external_policy.rs

1//! [`KindTieredHandler`] — a composable [`ExternalFnHandler`] wrapper that
2//! gates externals during speculative/watch evaluation by their effect
3//! kind, without the runtime knowing anything about a host manifest.
4//!
5//! The runtime stays **manifest-blind**: this module has no dependency on
6//! `brink-ir` or any analyzer type. [`PolicyKind`] is plain data — the
7//! consumer (e.g. celeris, bevy-brink) maps the analyzer's `ExternalKind`
8//! onto it (`Query` → [`PolicyKind::Query`]; `Effect`/`Presentation`/
9//! unclassified → [`PolicyKind::Effect`], conservative-by-default) and
10//! hands the resulting `name -> PolicyKind` table to
11//! [`KindTieredHandler::new`].
12//!
13//! Like [`crate::RecordingHandler`]/[`crate::ReplayHandler`], this *wraps*
14//! a real [`ExternalFnHandler`] rather than threading a policy through the
15//! stepping hot loop — the caller stacks it: e.g.
16//! `spec.advance(budget, &KindTieredHandler::new(&real_bindings, kinds, EvalContext::Watch, false))`.
17
18use core::cell::RefCell;
19
20use alloc::borrow::ToOwned;
21use alloc::string::String;
22use alloc::vec::Vec;
23
24use brink_format::Value;
25
26use crate::collections::Map as HashMap;
27use crate::story::{ExternalFnHandler, ExternalResult};
28
29/// The effect category a [`KindTieredHandler`] gates on. Plain data — the
30/// consumer maps its own (richer) external classification onto this
31/// two-way split.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum PolicyKind {
34    /// A read-only query (no side effects). Always delegated live.
35    Query,
36    /// A state-changing (or otherwise non-query) effect. Gated by
37    /// [`EvalContext`] and the handler's `live_effects` arming.
38    Effect,
39}
40
41/// Which evaluation regime a [`KindTieredHandler`] is gating for.
42///
43/// `Watch` is the conservative default: no effect ever fires live,
44/// regardless of arming. `Eval` additionally requires `live_effects` to be
45/// armed before an effect is allowed through — a deliberate two-key gate
46/// so effects don't fire just because the caller happens to be in an
47/// engine→ink eval.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum EvalContext {
50    /// Read-only inspection (e.g. a live-inspector "what would this show"
51    /// probe). Effects never fire live.
52    Watch,
53    /// An engine→ink function evaluation that may be permitted to run
54    /// live effects if `live_effects` is armed.
55    Eval,
56}
57
58/// Diagnostic record of which externals a [`KindTieredHandler`] let
59/// through live versus fell back, across the handler's lifetime.
60///
61/// Purely informational — nothing in the runtime reads this back, and its
62/// ordering (call order) has no bearing on story-visible behavior.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct ExternalsReport {
65    /// Ink-declared names of externals delegated to the real handler
66    /// (live), in call order.
67    pub live: Vec<String>,
68    /// Ink-declared names of externals that fell back to the ink fallback
69    /// body (blocked by tiering, or a `Watch`/disarmed `Effect`), in call
70    /// order.
71    pub fallback: Vec<String>,
72}
73
74/// A stackable [`ExternalFnHandler`] that tiers externals by
75/// [`PolicyKind`] before delegating to a real handler.
76///
77/// - `Query` externals are always delegated live to `inner` (including a
78///   `Pending` result, passed straight through for async resolution).
79/// - `Effect` externals are delegated live only when `context ==
80///   EvalContext::Eval` *and* `live_effects` is armed; otherwise they
81///   resolve to [`ExternalResult::Fallback`] (the ink fallback body, or a
82///   named-external VM error if none is declared).
83/// - A name absent from `kinds` is treated as `Effect` — conservative by
84///   default.
85///
86/// No coupling to [`crate::Speculation`] — this is a plain composable
87/// handler, usable anywhere an `&dyn ExternalFnHandler` is accepted.
88pub struct KindTieredHandler<'h> {
89    inner: &'h dyn ExternalFnHandler,
90    kinds: HashMap<String, PolicyKind>,
91    context: EvalContext,
92    live_effects: bool,
93    report: RefCell<ExternalsReport>,
94}
95
96impl<'h> KindTieredHandler<'h> {
97    /// Build a handler gating calls to `inner` by `kinds`.
98    ///
99    /// `kinds` maps ink-declared external names to their [`PolicyKind`];
100    /// a name absent from the map is treated as `Effect`. `context` picks
101    /// the evaluation regime; `live_effects` arms `Effect` externals when
102    /// `context == EvalContext::Eval` (ignored under `Watch`, where
103    /// effects never fire live).
104    #[must_use]
105    pub fn new(
106        inner: &'h dyn ExternalFnHandler,
107        kinds: HashMap<String, PolicyKind>,
108        context: EvalContext,
109        live_effects: bool,
110    ) -> Self {
111        Self {
112            inner,
113            kinds,
114            context,
115            live_effects,
116            report: RefCell::new(ExternalsReport::default()),
117        }
118    }
119
120    /// Snapshot of which externals have run live versus fallen back so
121    /// far. Diagnostic only.
122    #[must_use]
123    pub fn report(&self) -> ExternalsReport {
124        self.report.borrow().clone()
125    }
126
127    /// Whether an external of the given kind is allowed to run live under
128    /// this handler's current context/arming.
129    fn armed(&self, kind: PolicyKind) -> bool {
130        match kind {
131            PolicyKind::Query => true,
132            PolicyKind::Effect => self.context == EvalContext::Eval && self.live_effects,
133        }
134    }
135}
136
137impl ExternalFnHandler for KindTieredHandler<'_> {
138    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
139        let kind = self.kinds.get(name).copied().unwrap_or(PolicyKind::Effect);
140        if self.armed(kind) {
141            self.report.borrow_mut().live.push(name.to_owned());
142            self.inner.call(name, args)
143        } else {
144            self.report.borrow_mut().fallback.push(name.to_owned());
145            ExternalResult::Fallback
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// A stub handler: `Resolved` for names in its table, `Pending` for
155    /// the sentinel name `"async"`, else `Fallback`.
156    struct Stub;
157    impl ExternalFnHandler for Stub {
158        fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
159            match name {
160                "async" => ExternalResult::Pending,
161                "get" => ExternalResult::Resolved(Value::Int(5)),
162                "act" => ExternalResult::Resolved(Value::Bool(true)),
163                _ => ExternalResult::Fallback,
164            }
165        }
166    }
167
168    fn kinds(pairs: &[(&str, PolicyKind)]) -> HashMap<String, PolicyKind> {
169        pairs.iter().map(|(n, k)| ((*n).to_owned(), *k)).collect()
170    }
171
172    #[test]
173    fn query_delegates_live_including_pending() {
174        let inner = Stub;
175        let h = KindTieredHandler::new(
176            &inner,
177            kinds(&[("get", PolicyKind::Query), ("async", PolicyKind::Query)]),
178            EvalContext::Watch,
179            false,
180        );
181        assert!(matches!(
182            h.call("get", &[]),
183            ExternalResult::Resolved(Value::Int(5))
184        ));
185        assert!(matches!(h.call("async", &[]), ExternalResult::Pending));
186        assert_eq!(h.report().live, vec!["get".to_owned(), "async".to_owned()]);
187        assert!(h.report().fallback.is_empty());
188    }
189
190    #[test]
191    fn effect_under_watch_falls_back_inner_not_called() {
192        let inner = Stub;
193        let h = KindTieredHandler::new(
194            &inner,
195            kinds(&[("act", PolicyKind::Effect)]),
196            EvalContext::Watch,
197            true, // even armed, Watch never lets effects through
198        );
199        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
200        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
201        assert!(h.report().live.is_empty());
202    }
203
204    #[test]
205    fn effect_under_eval_disarmed_falls_back() {
206        let inner = Stub;
207        let h = KindTieredHandler::new(
208            &inner,
209            kinds(&[("act", PolicyKind::Effect)]),
210            EvalContext::Eval,
211            false,
212        );
213        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
214        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
215    }
216
217    #[test]
218    fn effect_under_eval_armed_runs_live() {
219        let inner = Stub;
220        let h = KindTieredHandler::new(
221            &inner,
222            kinds(&[("act", PolicyKind::Effect)]),
223            EvalContext::Eval,
224            true,
225        );
226        assert!(matches!(
227            h.call("act", &[]),
228            ExternalResult::Resolved(Value::Bool(true))
229        ));
230        assert_eq!(h.report().live, vec!["act".to_owned()]);
231    }
232
233    #[test]
234    fn unclassified_name_treated_as_effect() {
235        let inner = Stub;
236        // "act" is absent from `kinds` entirely.
237        let h = KindTieredHandler::new(&inner, kinds(&[]), EvalContext::Watch, false);
238        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
239        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
240
241        // Even armed + Eval, an unclassified name is still conservative
242        // Effect tiering — it runs live only because Effect is armed here,
243        // proving it wasn't silently treated as Query.
244        let h2 = KindTieredHandler::new(&inner, kinds(&[]), EvalContext::Eval, true);
245        assert!(matches!(
246            h2.call("act", &[]),
247            ExternalResult::Resolved(Value::Bool(true))
248        ));
249        assert_eq!(h2.report().live, vec!["act".to_owned()]);
250    }
251
252    #[test]
253    fn report_reflects_mixed_live_and_fallback_in_call_order() {
254        let inner = Stub;
255        let h = KindTieredHandler::new(
256            &inner,
257            kinds(&[("get", PolicyKind::Query), ("act", PolicyKind::Effect)]),
258            EvalContext::Watch,
259            false,
260        );
261        let _ = h.call("get", &[]);
262        let _ = h.call("act", &[]);
263        let _ = h.call("get", &[]);
264        let report = h.report();
265        assert_eq!(report.live, vec!["get".to_owned(), "get".to_owned()]);
266        assert_eq!(report.fallback, vec!["act".to_owned()]);
267    }
268}