Skip to main content

deepstrike_core/runtime/kernel/wire/
projection.rs

1//! Pure projection helpers from the canonical Kernel Wire contract to host-facing facts.
2//!
3//! This module intentionally contains no host I/O, clocks, randomness, or runtime state.  The
4//! first slice centralises publication-manifest extraction; action projection is added on top of
5//! the same boundary in the following TDD cards.
6
7use serde::{Deserialize, Serialize};
8
9use super::driver::PlannedStep;
10use super::effect::{
11    ArchivePageOutEffect, CallProviderEffect, EffectKind, EffectKindTag, EvaluateMilestoneEffect,
12    ExecuteToolsEffect, KernelEffect, LoadPayloadEffect, PersistMemoryEffect,
13    PreemptTasksEffect, QueryMemoryEffect, RequestApprovalEffect, SpawnTasksEffect,
14};
15use super::scalar::EffectId;
16
17/// The minimal fact a host may append to its event log for a committed step.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct PublishedEffectRef {
21    pub effect_id: EffectId,
22    pub kind: EffectKindTag,
23}
24
25/// Host-facing action with one canonical effect payload.  Payload structs are the existing wire
26/// structs; this first slice centralises selection without inventing a second payload schema.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum CanonicalHostAction {
30    CallProvider {
31        effect_id: EffectId,
32        causation_input_id: super::scalar::InputId,
33        payload: CallProviderEffect,
34    },
35    ExecuteTools {
36        effect_id: EffectId,
37        causation_input_id: super::scalar::InputId,
38        payload: ExecuteToolsEffect,
39    },
40    RequestApproval {
41        effect_id: EffectId,
42        causation_input_id: super::scalar::InputId,
43        payload: RequestApprovalEffect,
44    },
45    SpawnTasks {
46        effect_id: EffectId,
47        causation_input_id: super::scalar::InputId,
48        payload: SpawnTasksEffect,
49    },
50    PreemptTasks {
51        effect_id: EffectId,
52        causation_input_id: super::scalar::InputId,
53        payload: PreemptTasksEffect,
54    },
55    PersistMemory {
56        effect_id: EffectId,
57        causation_input_id: super::scalar::InputId,
58        payload: PersistMemoryEffect,
59    },
60    QueryMemory {
61        effect_id: EffectId,
62        causation_input_id: super::scalar::InputId,
63        payload: QueryMemoryEffect,
64    },
65    ArchivePageOut {
66        effect_id: EffectId,
67        causation_input_id: super::scalar::InputId,
68        payload: ArchivePageOutEffect,
69    },
70    LoadPayload {
71        effect_id: EffectId,
72        causation_input_id: super::scalar::InputId,
73        payload: LoadPayloadEffect,
74    },
75    EvaluateMilestone {
76        effect_id: EffectId,
77        causation_input_id: super::scalar::InputId,
78        payload: EvaluateMilestoneEffect,
79    },
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(tag = "state", content = "action", rename_all = "snake_case")]
84pub enum KernelProjection {
85    Idle,
86    Action(CanonicalHostAction),
87    Terminal(super::terminal::KernelTerminal),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ProjectionError {
92    pub message: String,
93}
94
95/// Project one wire effect without touching host state.
96pub fn project_effect(effect: &KernelEffect) -> CanonicalHostAction {
97    let effect_id = effect.effect_id.clone();
98    let causation_input_id = effect.causation_input_id.clone();
99    match &effect.effect {
100        EffectKind::CallProvider(payload) => CanonicalHostAction::CallProvider {
101            effect_id,
102            causation_input_id,
103            payload: payload.clone(),
104        },
105        EffectKind::ExecuteTools(payload) => CanonicalHostAction::ExecuteTools {
106            effect_id,
107            causation_input_id,
108            payload: payload.clone(),
109        },
110        EffectKind::RequestApproval(payload) => CanonicalHostAction::RequestApproval {
111            effect_id,
112            causation_input_id,
113            payload: payload.clone(),
114        },
115        EffectKind::SpawnTasks(payload) => CanonicalHostAction::SpawnTasks {
116            effect_id,
117            causation_input_id,
118            payload: payload.clone(),
119        },
120        EffectKind::PreemptTasks(payload) => CanonicalHostAction::PreemptTasks {
121            effect_id,
122            causation_input_id,
123            payload: payload.clone(),
124        },
125        EffectKind::PersistMemory(payload) => CanonicalHostAction::PersistMemory {
126            effect_id,
127            causation_input_id,
128            payload: payload.clone(),
129        },
130        EffectKind::QueryMemory(payload) => CanonicalHostAction::QueryMemory {
131            effect_id,
132            causation_input_id,
133            payload: payload.clone(),
134        },
135        EffectKind::ArchivePageOut(payload) => CanonicalHostAction::ArchivePageOut {
136            effect_id,
137            causation_input_id,
138            payload: payload.clone(),
139        },
140        EffectKind::LoadPayload(payload) => CanonicalHostAction::LoadPayload {
141            effect_id,
142            causation_input_id,
143            payload: payload.clone(),
144        },
145        EffectKind::EvaluateMilestone(payload) => CanonicalHostAction::EvaluateMilestone {
146            effect_id,
147            causation_input_id,
148            payload: payload.clone(),
149        },
150    }
151}
152
153/// Select the current host action from one committed step.  The step's vector is already the
154/// kernel's publication order, so the first effect is the only legal current action.
155pub fn project_action(step: &PlannedStep) -> Result<KernelProjection, ProjectionError> {
156    project_pending_action(step.disposition.terminal(), step.disposition.effects())
157}
158
159/// Return the first effect in a committed step's publication order. Transitional SDK adapters
160/// use this selector while their payload conversion is migrated to [`project_effect`].
161pub fn current_effect(step: &PlannedStep) -> Option<&KernelEffect> {
162    step.disposition.effects().first()
163}
164
165/// JSON bridge used by bindings for transitions that carry a planned step directly.
166pub fn project_planned_step_json(raw: &str) -> Result<String, ProjectionError> {
167    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
168        message: format!("invalid planned step: {error}"),
169    })?;
170    let projection = project_action(&step)?;
171    serde_json::to_string(&projection).map_err(|error| ProjectionError {
172        message: format!("projection serialization failed: {error}"),
173    })
174}
175
176/// JSON bridge for the host audit manifest. This intentionally excludes payloads and preserves
177/// the publication order already encoded in the planned step.
178pub fn published_effects_manifest_json(raw: &str) -> Result<String, ProjectionError> {
179    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
180        message: format!("invalid planned step: {error}"),
181    })?;
182    serde_json::to_string(&published_effects_manifest(&step)).map_err(|error| ProjectionError {
183        message: format!("manifest serialization failed: {error}"),
184    })
185}
186
187/// Project the current action from the transaction's already ordered pending-effect view.
188/// Ordering is deliberately owned by `KernelTransaction::pending_effects_in_order`; this helper
189/// only selects the head and maps it to a canonical action.
190pub fn project_pending_action<'a, I>(
191    terminal: Option<&super::terminal::KernelTerminal>,
192    effects: I,
193) -> Result<KernelProjection, ProjectionError>
194where
195    I: IntoIterator<Item = &'a KernelEffect>,
196{
197    if let Some(terminal) = terminal {
198        return Ok(KernelProjection::Terminal(terminal.clone()));
199    }
200    match effects.into_iter().next() {
201        Some(effect) => Ok(KernelProjection::Action(project_effect(effect))),
202        None => Ok(KernelProjection::Idle),
203    }
204}
205
206/// Extract effects in the step's publication/mint order.
207///
208/// A terminal step publishes no effects.  The function deliberately preserves the vector order
209/// from `PlannedStep`; re-sorting by map keys here would reintroduce the step:10-before-step:9
210/// bug that this projection boundary exists to prevent.
211pub fn published_effects_manifest(step: &PlannedStep) -> Vec<PublishedEffectRef> {
212    step.disposition.effects().iter().map(effect_ref).collect()
213}
214
215fn effect_ref(effect: &KernelEffect) -> PublishedEffectRef {
216    PublishedEffectRef {
217        effect_id: effect.effect_id.clone(),
218        kind: effect.tag(),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{
225        CanonicalHostAction, KernelProjection, project_action, project_effect,
226        project_pending_action, published_effects_manifest,
227    };
228    use crate::runtime::kernel::wire::{EffectKindTag, PlannedStep};
229
230    #[test]
231    fn manifest_preserves_multi_effect_publication_order() {
232        let fixture: serde_json::Value = serde_json::from_str(include_str!(
233            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
234        ))
235        .expect("fixture JSON");
236        let step: PlannedStep =
237            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
238
239        let manifest = published_effects_manifest(&step);
240
241        assert_eq!(manifest.len(), 2);
242        assert_eq!(
243            manifest[0].effect_id.as_str(),
244            "op-contract:step:9:effect:0"
245        );
246        assert_eq!(manifest[0].kind, EffectKindTag::QueryMemory);
247        assert_eq!(
248            manifest[1].effect_id.as_str(),
249            "op-contract:step:9:effect:1"
250        );
251        assert_eq!(manifest[1].kind, EffectKindTag::ExecuteTools);
252    }
253
254    #[test]
255    fn terminal_step_has_empty_manifest() {
256        let step = PlannedStep {
257            root_kind: None,
258            focus: None,
259            observations: Vec::new(),
260            disposition: crate::runtime::kernel::wire::StepDisposition::Terminal(
261                crate::runtime::kernel::wire::TerminalDisposition {
262                    terminal: crate::runtime::kernel::wire::KernelTerminal::Cancelled(
263                        crate::runtime::kernel::wire::CancelledTerminal {
264                            reason: crate::runtime::kernel::wire::CancellationReason::HostShutdown,
265                            usage: crate::runtime::kernel::wire::UsageReport {
266                                input_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
267                                output_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
268                                turns: 0,
269                                cached_input_tokens: None,
270                            },
271                        },
272                    ),
273                },
274            ),
275        };
276
277        assert!(published_effects_manifest(&step).is_empty());
278    }
279
280    #[test]
281    fn projects_the_first_query_memory_effect_to_a_typed_action() {
282        let fixture: serde_json::Value = serde_json::from_str(include_str!(
283            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
284        ))
285        .expect("fixture JSON");
286        let step: PlannedStep =
287            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
288        let effect = step.disposition.effects().first().expect("first effect");
289
290        let action = project_effect(effect);
291
292        match action {
293            CanonicalHostAction::QueryMemory {
294                effect_id, payload, ..
295            } => {
296                assert_eq!(effect_id.as_str(), "op-contract:step:9:effect:0");
297                assert_eq!(payload.query.text, "past briefs");
298                assert_eq!(payload.requested_k, 4);
299            }
300            other => panic!("expected query_memory action, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn canonical_action_serialization_keeps_wire_kind_and_identity() {
306        let fixture: serde_json::Value = serde_json::from_str(include_str!(
307            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
308        ))
309        .expect("fixture JSON");
310        let step: PlannedStep =
311            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
312        let action = project_effect(step.disposition.effects().first().expect("effect"));
313        let value = serde_json::to_value(action).expect("action JSON");
314
315        assert_eq!(value["kind"], "query_memory");
316        assert_eq!(value["effect_id"], "op-contract:step:9:effect:0");
317        assert_eq!(value["causation_input_id"], "in-9");
318        assert_eq!(value["payload"]["requested_k"], 4);
319        assert_eq!(value["payload"]["query"]["text"], "past briefs");
320    }
321
322    #[test]
323    fn projection_serialization_has_explicit_state_tag() {
324        let fixture: serde_json::Value = serde_json::from_str(include_str!(
325            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
326        ))
327        .expect("fixture JSON");
328        let step: PlannedStep =
329            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
330        let value = serde_json::to_value(project_action(&step).expect("projection"))
331            .expect("projection JSON");
332
333        assert_eq!(value["state"], "action");
334        assert_eq!(value["action"]["kind"], "query_memory");
335        assert_eq!(value["action"]["effect_id"], "op-contract:step:9:effect:0");
336    }
337
338    #[test]
339    fn planned_step_json_bridge_matches_projection() {
340        let fixture: serde_json::Value = serde_json::from_str(include_str!(
341            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
342        ))
343        .expect("fixture JSON");
344        let output = super::project_planned_step_json(
345            &serde_json::to_string(&fixture["planned_step"]).expect("planned step JSON"),
346        )
347        .expect("projection JSON");
348        let value: serde_json::Value = serde_json::from_str(&output).expect("projection");
349        assert_eq!(value["state"], "action");
350        assert_eq!(value["action"]["kind"], "query_memory");
351    }
352
353    #[test]
354    fn projection_selects_first_effect_and_distinguishes_idle() {
355        let fixture: serde_json::Value = serde_json::from_str(include_str!(
356            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
357        ))
358        .expect("fixture JSON");
359        let step: PlannedStep =
360            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
361
362        let projection = project_action(&step).expect("projection");
363        assert!(matches!(
364            projection,
365            KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
366        ));
367
368        let idle = PlannedStep {
369            root_kind: None,
370            focus: None,
371            observations: Vec::new(),
372            disposition: crate::runtime::kernel::wire::StepDisposition::Effects(Default::default()),
373        };
374        assert!(matches!(
375            project_action(&idle).expect("projection"),
376            KernelProjection::Idle
377        ));
378
379        let ordered = step.disposition.effects().iter();
380        assert!(matches!(
381            project_pending_action(None, ordered).expect("projection"),
382            KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
383        ));
384    }
385}