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, MeasurePromptEffect, 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    MeasurePrompt {
81        effect_id: EffectId,
82        causation_input_id: super::scalar::InputId,
83        payload: MeasurePromptEffect,
84    },
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "state", content = "action", rename_all = "snake_case")]
89pub enum CurrentProjection {
90    Idle,
91    Action(CanonicalHostAction),
92    Terminal(super::terminal::KernelTerminal),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ProjectionError {
97    pub message: String,
98}
99
100/// Project one wire effect without touching host state.
101pub fn project_effect(effect: &KernelEffect) -> CanonicalHostAction {
102    let effect_id = effect.effect_id.clone();
103    let causation_input_id = effect.causation_input_id.clone();
104    match &effect.effect {
105        EffectKind::CallProvider(payload) => CanonicalHostAction::CallProvider {
106            effect_id,
107            causation_input_id,
108            payload: payload.clone(),
109        },
110        EffectKind::ExecuteTools(payload) => CanonicalHostAction::ExecuteTools {
111            effect_id,
112            causation_input_id,
113            payload: payload.clone(),
114        },
115        EffectKind::RequestApproval(payload) => CanonicalHostAction::RequestApproval {
116            effect_id,
117            causation_input_id,
118            payload: payload.clone(),
119        },
120        EffectKind::SpawnTasks(payload) => CanonicalHostAction::SpawnTasks {
121            effect_id,
122            causation_input_id,
123            payload: payload.clone(),
124        },
125        EffectKind::PreemptTasks(payload) => CanonicalHostAction::PreemptTasks {
126            effect_id,
127            causation_input_id,
128            payload: payload.clone(),
129        },
130        EffectKind::PersistMemory(payload) => CanonicalHostAction::PersistMemory {
131            effect_id,
132            causation_input_id,
133            payload: payload.clone(),
134        },
135        EffectKind::QueryMemory(payload) => CanonicalHostAction::QueryMemory {
136            effect_id,
137            causation_input_id,
138            payload: payload.clone(),
139        },
140        EffectKind::ArchivePageOut(payload) => CanonicalHostAction::ArchivePageOut {
141            effect_id,
142            causation_input_id,
143            payload: payload.clone(),
144        },
145        EffectKind::LoadPayload(payload) => CanonicalHostAction::LoadPayload {
146            effect_id,
147            causation_input_id,
148            payload: payload.clone(),
149        },
150        EffectKind::EvaluateMilestone(payload) => CanonicalHostAction::EvaluateMilestone {
151            effect_id,
152            causation_input_id,
153            payload: payload.clone(),
154        },
155        EffectKind::MeasurePrompt(payload) => CanonicalHostAction::MeasurePrompt {
156            effect_id,
157            causation_input_id,
158            payload: payload.clone(),
159        },
160    }
161}
162
163/// Select the current host action from one committed step.  The step's vector is already the
164/// kernel's publication order, so the first effect is the only legal current action.
165pub fn project_current_action(step: &PlannedStep) -> Result<CurrentProjection, ProjectionError> {
166    project_current_pending_action(step.disposition.terminal(), step.disposition.effects())
167}
168
169/// Return the first effect in a committed step's publication order. Transitional SDK adapters
170/// use this selector while their payload conversion is migrated to [`project_effect`].
171pub fn current_effect(step: &PlannedStep) -> Option<&KernelEffect> {
172    step.disposition.effects().first()
173}
174
175/// JSON bridge used by bindings for transitions that carry a planned step directly.
176pub fn project_planned_step_json(raw: &str) -> Result<String, ProjectionError> {
177    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
178        message: format!("invalid planned step: {error}"),
179    })?;
180    let projection = project_current_action(&step)?;
181    serde_json::to_string(&projection).map_err(|error| ProjectionError {
182        message: format!("projection serialization failed: {error}"),
183    })
184}
185
186/// JSON bridge for the host audit manifest. This intentionally excludes payloads and preserves
187/// the publication order already encoded in the planned step.
188pub fn published_effects_manifest_json(raw: &str) -> Result<String, ProjectionError> {
189    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
190        message: format!("invalid planned step: {error}"),
191    })?;
192    serde_json::to_string(&published_effects_manifest(&step)).map_err(|error| ProjectionError {
193        message: format!("manifest serialization failed: {error}"),
194    })
195}
196
197/// Project the current action from the transaction's already ordered pending-effect view.
198/// Ordering is deliberately owned by `KernelTransaction::pending_effects_in_order`; this helper
199/// only selects the head and maps it to a canonical action.
200pub fn project_current_pending_action<'a, I>(
201    terminal: Option<&super::terminal::KernelTerminal>,
202    effects: I,
203) -> Result<CurrentProjection, ProjectionError>
204where
205    I: IntoIterator<Item = &'a KernelEffect>,
206{
207    if let Some(terminal) = terminal {
208        return Ok(CurrentProjection::Terminal(terminal.clone()));
209    }
210    match effects.into_iter().next() {
211        Some(effect) => Ok(CurrentProjection::Action(project_effect(effect))),
212        None => Ok(CurrentProjection::Idle),
213    }
214}
215
216/// Extract effects in the step's publication/mint order.
217///
218/// A terminal step publishes no effects.  The function deliberately preserves the vector order
219/// from `PlannedStep`; re-sorting by map keys here would reintroduce the step:10-before-step:9
220/// bug that this projection boundary exists to prevent.
221pub fn published_effects_manifest(step: &PlannedStep) -> Vec<PublishedEffectRef> {
222    step.disposition.effects().iter().map(effect_ref).collect()
223}
224
225fn effect_ref(effect: &KernelEffect) -> PublishedEffectRef {
226    PublishedEffectRef {
227        effect_id: effect.effect_id.clone(),
228        kind: effect.tag(),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        CanonicalHostAction, CurrentProjection, project_current_action,
236        project_current_pending_action, project_effect, published_effects_manifest,
237    };
238    use crate::runtime::kernel::wire::{EffectKindTag, PlannedStep};
239
240    #[test]
241    fn manifest_preserves_multi_effect_publication_order() {
242        let fixture: serde_json::Value = serde_json::from_str(include_str!(
243            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
244        ))
245        .expect("fixture JSON");
246        let step: PlannedStep =
247            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
248
249        let manifest = published_effects_manifest(&step);
250
251        assert_eq!(manifest.len(), 2);
252        assert_eq!(
253            manifest[0].effect_id.as_str(),
254            "op-contract:step:9:effect:0"
255        );
256        assert_eq!(manifest[0].kind, EffectKindTag::QueryMemory);
257        assert_eq!(
258            manifest[1].effect_id.as_str(),
259            "op-contract:step:9:effect:1"
260        );
261        assert_eq!(manifest[1].kind, EffectKindTag::ExecuteTools);
262    }
263
264    #[test]
265    fn terminal_step_has_empty_manifest() {
266        let step = PlannedStep {
267            root_kind: None,
268            focus: None,
269            observations: Vec::new(),
270            disposition: crate::runtime::kernel::wire::StepDisposition::Terminal(
271                crate::runtime::kernel::wire::TerminalDisposition {
272                    terminal: crate::runtime::kernel::wire::KernelTerminal::Cancelled(
273                        crate::runtime::kernel::wire::CancelledTerminal {
274                            reason: crate::runtime::kernel::wire::CancellationReason::HostShutdown,
275                            usage: crate::runtime::kernel::wire::UsageReport {
276                                input_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
277                                output_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
278                                turns: 0,
279                                cached_input_tokens: None,
280                            },
281                        },
282                    ),
283                },
284            ),
285        };
286
287        assert!(published_effects_manifest(&step).is_empty());
288    }
289
290    #[test]
291    fn projects_the_first_query_memory_effect_to_a_typed_action() {
292        let fixture: serde_json::Value = serde_json::from_str(include_str!(
293            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
294        ))
295        .expect("fixture JSON");
296        let step: PlannedStep =
297            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
298        let effect = step.disposition.effects().first().expect("first effect");
299
300        let action = project_effect(effect);
301
302        match action {
303            CanonicalHostAction::QueryMemory {
304                effect_id, payload, ..
305            } => {
306                assert_eq!(effect_id.as_str(), "op-contract:step:9:effect:0");
307                assert_eq!(payload.query.text, "past briefs");
308                assert_eq!(payload.requested_k, 4);
309            }
310            other => panic!("expected query_memory action, got {other:?}"),
311        }
312    }
313
314    #[test]
315    fn canonical_action_serialization_keeps_wire_kind_and_identity() {
316        let fixture: serde_json::Value = serde_json::from_str(include_str!(
317            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
318        ))
319        .expect("fixture JSON");
320        let step: PlannedStep =
321            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
322        let action = project_effect(step.disposition.effects().first().expect("effect"));
323        let value = serde_json::to_value(action).expect("action JSON");
324
325        assert_eq!(value["kind"], "query_memory");
326        assert_eq!(value["effect_id"], "op-contract:step:9:effect:0");
327        assert_eq!(value["causation_input_id"], "in-9");
328        assert_eq!(value["payload"]["requested_k"], 4);
329        assert_eq!(value["payload"]["query"]["text"], "past briefs");
330    }
331
332    #[test]
333    fn current_projection_serialization_has_explicit_state_tag() {
334        let fixture: serde_json::Value = serde_json::from_str(include_str!(
335            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
336        ))
337        .expect("fixture JSON");
338        let step: PlannedStep =
339            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
340        let value = serde_json::to_value(project_current_action(&step).expect("projection"))
341            .expect("projection JSON");
342
343        assert_eq!(value["state"], "action");
344        assert_eq!(value["action"]["kind"], "query_memory");
345        assert_eq!(value["action"]["effect_id"], "op-contract:step:9:effect:0");
346    }
347
348    #[test]
349    fn planned_step_json_bridge_matches_current_projection() {
350        let fixture: serde_json::Value = serde_json::from_str(include_str!(
351            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
352        ))
353        .expect("fixture JSON");
354        let output = super::project_planned_step_json(
355            &serde_json::to_string(&fixture["planned_step"]).expect("planned step JSON"),
356        )
357        .expect("projection JSON");
358        let value: serde_json::Value = serde_json::from_str(&output).expect("projection");
359        assert_eq!(value["state"], "action");
360        assert_eq!(value["action"]["kind"], "query_memory");
361    }
362
363    #[test]
364    fn current_projection_selects_first_effect_and_distinguishes_idle() {
365        let fixture: serde_json::Value = serde_json::from_str(include_str!(
366            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
367        ))
368        .expect("fixture JSON");
369        let step: PlannedStep =
370            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
371
372        let projection = project_current_action(&step).expect("projection");
373        assert!(matches!(
374            projection,
375            CurrentProjection::Action(CanonicalHostAction::QueryMemory { .. })
376        ));
377
378        let idle = PlannedStep {
379            root_kind: None,
380            focus: None,
381            observations: Vec::new(),
382            disposition: crate::runtime::kernel::wire::StepDisposition::Effects(Default::default()),
383        };
384        assert!(matches!(
385            project_current_action(&idle).expect("projection"),
386            CurrentProjection::Idle
387        ));
388
389        let ordered = step.disposition.effects().iter();
390        assert!(matches!(
391            project_current_pending_action(None, ordered).expect("projection"),
392            CurrentProjection::Action(CanonicalHostAction::QueryMemory { .. })
393        ));
394    }
395}