Skip to main content

meerkat_runtime/
policy.rs

1//! §12 PolicyDecision — the output of the policy table.
2//!
3//! The runtime's policy table resolves each Input to a PolicyDecision
4//! that determines how and when the input is applied, whether it wakes
5//! the runtime, how it's queued, and when it's consumed.
6
7use serde::{Deserialize, Serialize};
8
9use crate::identifiers::PolicyVersion;
10
11/// How the input should be applied to the conversation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum ApplyMode {
16    /// Stage for application at the start of the next run.
17    StageRunStart,
18    /// Stage for application at any run boundary (start or checkpoint).
19    StageRunBoundary,
20    /// Inject immediately (no run boundary required).
21    InjectNow,
22    /// Do not apply (input is informational only).
23    Ignore,
24}
25
26/// Whether the input should wake an idle runtime.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum WakeMode {
31    /// Wake the runtime if idle.
32    WakeIfIdle,
33    /// Interrupt cooperative yielding points (e.g., wait tool) but don't
34    /// cancel active work or wake an idle runtime.
35    InterruptYielding,
36    /// Do not wake (input will be processed at next natural run).
37    None,
38}
39
40/// Queue ordering discipline.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum QueueMode {
45    /// No queueing (immediate consumption).
46    None,
47    /// First-in, first-out ordering.
48    Fifo,
49    /// Coalesce with other inputs of the same type.
50    Coalesce,
51    /// Supersede earlier inputs with the same supersession key.
52    Supersede,
53    /// Priority ordering (higher priority first).
54    Priority,
55}
56
57/// When the input is considered consumed.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60#[non_exhaustive]
61pub enum ConsumePoint {
62    /// Consumed when the input is accepted.
63    OnAccept,
64    /// Consumed when the input is applied (boundary executed).
65    OnApply,
66    /// Consumed when the run starts.
67    OnRunStart,
68    /// Consumed when the run completes.
69    OnRunComplete,
70    /// Consumed only on explicit acknowledgment.
71    ExplicitAck,
72}
73
74/// Whether admitted work may interrupt a yielding runtime.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum InterruptPolicy {
79    #[default]
80    None,
81    InterruptYielding,
82}
83
84/// How the runtime should drain admitted work.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum DrainPolicy {
89    #[default]
90    QueueNextTurn,
91    SteerBatch,
92    Immediate,
93    Ignore,
94}
95
96/// Where admitted work routes after policy resolution.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
98#[serde(rename_all = "snake_case")]
99#[non_exhaustive]
100pub enum RoutingDisposition {
101    #[default]
102    Queue,
103    Steer,
104    Immediate,
105    Drop,
106}
107
108/// Full policy decision for an input.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct PolicyDecision {
111    /// How to apply the input.
112    pub apply_mode: ApplyMode,
113    /// Whether to wake the runtime.
114    pub wake_mode: WakeMode,
115    /// Queue ordering.
116    pub queue_mode: QueueMode,
117    /// When the input is consumed.
118    pub consume_point: ConsumePoint,
119    /// Whether yielding work may be interrupted.
120    #[serde(default)]
121    pub interrupt_policy: InterruptPolicy,
122    /// How runtime drain ownership should handle this work.
123    #[serde(default)]
124    pub drain_policy: DrainPolicy,
125    /// Where the work routes after admission.
126    #[serde(default)]
127    pub routing_disposition: RoutingDisposition,
128    /// Whether to record this input in the conversation transcript.
129    #[serde(default = "default_true")]
130    pub record_transcript: bool,
131    /// Whether to emit operator-visible content for this input.
132    #[serde(default = "default_true")]
133    pub emit_operator_content: bool,
134    /// Policy version that produced this decision.
135    pub policy_version: PolicyVersion,
136}
137
138fn default_true() -> bool {
139    true
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn apply_mode_serde() {
149        for mode in [
150            ApplyMode::StageRunStart,
151            ApplyMode::StageRunBoundary,
152            ApplyMode::InjectNow,
153            ApplyMode::Ignore,
154        ] {
155            let json = serde_json::to_value(mode).unwrap();
156            let parsed: ApplyMode = serde_json::from_value(json).unwrap();
157            assert_eq!(mode, parsed);
158        }
159    }
160
161    #[test]
162    fn wake_mode_serde() {
163        for mode in [
164            WakeMode::WakeIfIdle,
165            WakeMode::InterruptYielding,
166            WakeMode::None,
167        ] {
168            let json = serde_json::to_value(mode).unwrap();
169            let parsed: WakeMode = serde_json::from_value(json).unwrap();
170            assert_eq!(mode, parsed);
171        }
172    }
173
174    #[test]
175    fn queue_mode_serde() {
176        for mode in [
177            QueueMode::None,
178            QueueMode::Fifo,
179            QueueMode::Coalesce,
180            QueueMode::Supersede,
181            QueueMode::Priority,
182        ] {
183            let json = serde_json::to_value(mode).unwrap();
184            let parsed: QueueMode = serde_json::from_value(json).unwrap();
185            assert_eq!(mode, parsed);
186        }
187    }
188
189    #[test]
190    fn consume_point_serde() {
191        for point in [
192            ConsumePoint::OnAccept,
193            ConsumePoint::OnApply,
194            ConsumePoint::OnRunStart,
195            ConsumePoint::OnRunComplete,
196            ConsumePoint::ExplicitAck,
197        ] {
198            let json = serde_json::to_value(point).unwrap();
199            let parsed: ConsumePoint = serde_json::from_value(json).unwrap();
200            assert_eq!(point, parsed);
201        }
202    }
203
204    #[test]
205    fn interrupt_policy_serde() {
206        for policy in [InterruptPolicy::None, InterruptPolicy::InterruptYielding] {
207            let json = serde_json::to_value(policy).unwrap();
208            let parsed: InterruptPolicy = serde_json::from_value(json).unwrap();
209            assert_eq!(policy, parsed);
210        }
211    }
212
213    #[test]
214    fn drain_policy_serde() {
215        for policy in [
216            DrainPolicy::QueueNextTurn,
217            DrainPolicy::SteerBatch,
218            DrainPolicy::Immediate,
219            DrainPolicy::Ignore,
220        ] {
221            let json = serde_json::to_value(policy).unwrap();
222            let parsed: DrainPolicy = serde_json::from_value(json).unwrap();
223            assert_eq!(policy, parsed);
224        }
225    }
226
227    #[test]
228    fn routing_disposition_serde() {
229        for disposition in [
230            RoutingDisposition::Queue,
231            RoutingDisposition::Steer,
232            RoutingDisposition::Immediate,
233            RoutingDisposition::Drop,
234        ] {
235            let json = serde_json::to_value(disposition).unwrap();
236            let parsed: RoutingDisposition = serde_json::from_value(json).unwrap();
237            assert_eq!(disposition, parsed);
238        }
239    }
240
241    #[test]
242    fn policy_decision_serde_roundtrip() {
243        let decision = PolicyDecision {
244            apply_mode: ApplyMode::StageRunStart,
245            wake_mode: WakeMode::WakeIfIdle,
246            queue_mode: QueueMode::Fifo,
247            consume_point: ConsumePoint::OnRunComplete,
248            interrupt_policy: InterruptPolicy::None,
249            drain_policy: DrainPolicy::QueueNextTurn,
250            routing_disposition: RoutingDisposition::Queue,
251            record_transcript: true,
252            emit_operator_content: true,
253            policy_version: PolicyVersion(1),
254        };
255        let json = serde_json::to_value(&decision).unwrap();
256        let parsed: PolicyDecision = serde_json::from_value(json).unwrap();
257        assert_eq!(decision, parsed);
258    }
259
260    #[test]
261    fn policy_decision_ignore_on_accept() {
262        let decision = PolicyDecision {
263            apply_mode: ApplyMode::Ignore,
264            wake_mode: WakeMode::None,
265            queue_mode: QueueMode::None,
266            consume_point: ConsumePoint::OnAccept,
267            interrupt_policy: InterruptPolicy::None,
268            drain_policy: DrainPolicy::Ignore,
269            routing_disposition: RoutingDisposition::Drop,
270            record_transcript: false,
271            emit_operator_content: false,
272            policy_version: PolicyVersion(1),
273        };
274        let json = serde_json::to_value(&decision).unwrap();
275        let parsed: PolicyDecision = serde_json::from_value(json).unwrap();
276        assert_eq!(decision, parsed);
277    }
278
279    #[test]
280    fn record_transcript_defaults_true() {
281        let json = serde_json::json!({
282            "apply_mode": "stage_run_start",
283            "wake_mode": "wake_if_idle",
284            "queue_mode": "fifo",
285            "consume_point": "on_run_complete",
286            "policy_version": 1
287        });
288        let parsed: PolicyDecision = serde_json::from_value(json).unwrap();
289        assert!(parsed.record_transcript);
290        assert!(parsed.emit_operator_content);
291    }
292}