Skip to main content

presolve_compiler/
effect_resume.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    ApplicationSemanticModel, Effect, EffectExecutionPolicy, EffectRenderBoundary,
7    EffectValidation, ExecutionBoundary, RuntimeEffectRegistry, SemanticId, SourceProvenance,
8};
9
10/// A stable mutable-resume identity for an effect's initial activation lifecycle.
11///
12/// This identity is intentionally distinct from the effect semantic entity and
13/// from executable function, action-batch, computed-cache, and dirty-flag IDs.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct EffectActivationSlotId(String);
17
18impl EffectActivationSlotId {
19    #[must_use]
20    pub fn for_effect(effect: &Effect) -> Option<Self> {
21        effect.owner.entity_id().map(|component| {
22            Self(
23                component
24                    .effect_activation_slot(&effect.name)
25                    .as_str()
26                    .to_string(),
27            )
28        })
29    }
30
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37/// Compiler-owned lifecycle state for initial effect activation.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EffectActivationStatus {
41    Pending,
42    Completed,
43    Failed,
44}
45
46/// The exact F9 placement of an initially activatable effect.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EffectInitialResumeMembership {
49    pub render_boundary: EffectRenderBoundary,
50    pub batch_index: u32,
51}
52
53/// Immutable compiler-owned resumability metadata for one runtime effect.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct EffectResumeRecord {
56    pub effect: SemanticId,
57    pub activation_slot: Option<EffectActivationSlotId>,
58    pub initial_status: Option<EffectActivationStatus>,
59    pub execution_policy: EffectExecutionPolicy,
60    pub runtime_function: SemanticId,
61    pub initial_plan_membership: Option<EffectInitialResumeMembership>,
62    pub action_batches: Vec<SemanticId>,
63    pub boundary: ExecutionBoundary,
64    pub provenance: SourceProvenance,
65}
66
67/// Deterministic F16 projection over runtime-effect registry membership.
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct EffectResumePlan {
70    pub records: Vec<EffectResumeRecord>,
71}
72
73impl EffectResumePlan {
74    #[must_use]
75    pub fn record(&self, effect: &SemanticId) -> Option<&EffectResumeRecord> {
76        self.records.iter().find(|record| &record.effect == effect)
77    }
78
79    #[must_use]
80    pub fn activation_slot(&self, effect: &SemanticId) -> Option<&EffectActivationSlotId> {
81        self.record(effect)
82            .and_then(|record| record.activation_slot.as_ref())
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EffectResumeValidationDiagnostic {
88    pub code: String,
89    pub message: String,
90}
91
92/// Project F1 identity/policy, F9 membership, and F12 runtime records into
93/// compiler-owned resumability metadata. This function never rereads effect
94/// syntax, dependencies, capability operations, or runtime evidence.
95#[must_use]
96pub fn build_effect_resume_plan(
97    model: &ApplicationSemanticModel,
98    registry: &RuntimeEffectRegistry,
99) -> EffectResumePlan {
100    let records = registry
101        .records
102        .iter()
103        .filter_map(|(effect_id, runtime)| {
104            let effect = model.effects.get(effect_id)?;
105            (effect.validation == EffectValidation::Valid).then_some((effect, runtime))
106        })
107        .filter_map(|(effect, runtime)| {
108            let initial_plan_membership =
109                runtime
110                    .initial_trigger
111                    .as_ref()
112                    .map(|trigger| EffectInitialResumeMembership {
113                        render_boundary: trigger.render_boundary,
114                        batch_index: trigger.effect_batch_index,
115                    });
116            let activation_slot = initial_plan_membership
117                .as_ref()
118                .and_then(|_| EffectActivationSlotId::for_effect(effect));
119            let action_batches = runtime
120                .action_batch_triggers
121                .iter()
122                .map(|trigger| trigger.action_batch.clone())
123                .collect::<BTreeSet<_>>()
124                .into_iter()
125                .collect::<Vec<_>>();
126            (initial_plan_membership.is_some() || !action_batches.is_empty()).then_some(
127                EffectResumeRecord {
128                    effect: effect.id.clone(),
129                    initial_status: activation_slot
130                        .as_ref()
131                        .map(|_| EffectActivationStatus::Pending),
132                    activation_slot,
133                    execution_policy: effect.execution_policy,
134                    runtime_function: runtime.execution_function.clone(),
135                    initial_plan_membership,
136                    action_batches,
137                    boundary: runtime.execution_boundary,
138                    provenance: effect.provenance.clone(),
139                },
140            )
141        })
142        .collect();
143    EffectResumePlan { records }
144}
145
146/// Validate that an F16 plan is a faithful projection of canonical inputs.
147#[must_use]
148#[allow(clippy::too_many_lines)]
149pub fn validate_effect_resume_plan(
150    model: &ApplicationSemanticModel,
151    registry: &RuntimeEffectRegistry,
152    plan: &EffectResumePlan,
153) -> Vec<EffectResumeValidationDiagnostic> {
154    let mut diagnostics = Vec::new();
155    let mut effects = BTreeSet::new();
156    let mut slots = BTreeSet::new();
157
158    for record in &plan.records {
159        if !effects.insert(record.effect.clone()) {
160            diagnostics.push(diagnostic(
161                "PSRSM1101",
162                "effect resume record is duplicated",
163            ));
164        }
165        let Some(effect) = model.effects.get(&record.effect) else {
166            diagnostics.push(diagnostic(
167                "PSRSM1102",
168                "effect resume record has no canonical effect",
169            ));
170            continue;
171        };
172        let Some(runtime) = registry.record(&record.effect) else {
173            diagnostics.push(diagnostic(
174                "PSRSM1103",
175                "effect resume record has no runtime effect record",
176            ));
177            continue;
178        };
179        if effect.validation != EffectValidation::Valid {
180            diagnostics.push(diagnostic(
181                "PSRSM1104",
182                "invalid effect has a resume record",
183            ));
184        }
185        if record.runtime_function != runtime.execution_function
186            || record.runtime_function.as_str().is_empty()
187        {
188            diagnostics.push(diagnostic(
189                "PSRSM1105",
190                "effect resume record has no canonical runtime function",
191            ));
192        }
193        if record.execution_policy != effect.execution_policy
194            || record.boundary != runtime.execution_boundary
195        {
196            diagnostics.push(diagnostic(
197                "PSRSM1106",
198                "effect resume record mismatches canonical execution facts",
199            ));
200        }
201        let expected_initial =
202            runtime
203                .initial_trigger
204                .as_ref()
205                .map(|trigger| EffectInitialResumeMembership {
206                    render_boundary: trigger.render_boundary,
207                    batch_index: trigger.effect_batch_index,
208                });
209        if record.initial_plan_membership != expected_initial {
210            diagnostics.push(diagnostic(
211                "PSRSM1107",
212                "effect resume initial membership mismatches F9",
213            ));
214        }
215        let expected_slots = expected_initial
216            .as_ref()
217            .and_then(|_| EffectActivationSlotId::for_effect(effect));
218        if record.activation_slot != expected_slots {
219            diagnostics.push(diagnostic(
220                "PSRSM1108",
221                "effect activation slot does not match initial membership",
222            ));
223        }
224        if record.initial_status
225            != record
226                .activation_slot
227                .as_ref()
228                .map(|_| EffectActivationStatus::Pending)
229        {
230            diagnostics.push(diagnostic(
231                "PSRSM1109",
232                "effect activation status must be pending exactly when a slot exists",
233            ));
234        }
235        if let Some(slot) = &record.activation_slot {
236            if !slots.insert(slot.clone()) {
237                diagnostics.push(diagnostic(
238                    "PSRSM1110",
239                    "effect activation slot is duplicated",
240                ));
241            }
242        }
243        let expected_batches = runtime
244            .action_batch_triggers
245            .iter()
246            .map(|trigger| trigger.action_batch.clone())
247            .collect::<BTreeSet<_>>()
248            .into_iter()
249            .collect::<Vec<_>>();
250        if record.action_batches != expected_batches
251            || record
252                .action_batches
253                .iter()
254                .any(|batch| !model.effect_trigger_plan.action_batches.contains_key(batch))
255        {
256            diagnostics.push(diagnostic(
257                "PSRSM1111",
258                "effect resume action batches are not canonical F8 references",
259            ));
260        }
261    }
262
263    diagnostics
264}
265
266fn diagnostic(code: &str, message: &str) -> EffectResumeValidationDiagnostic {
267    EffectResumeValidationDiagnostic {
268        code: code.to_string(),
269        message: message.to_string(),
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use crate::{
276        build_application_semantic_model, build_effect_resume_plan, build_runtime_effect_registry,
277        lower_components_to_ir, optimize_effect_ir, validate_effect_resume_plan,
278        EffectActivationStatus,
279    };
280
281    #[test]
282    fn plans_stable_initial_slots_and_canonical_action_batches() {
283        let parsed = presolve_parser::parse_file(
284            "src/EffectResume.tsx",
285            r#"
286@component("x-effect-resume")
287class EffectResume extends Component {
288  count = state(1);
289  label = state("ready");
290
291  @computed()
292  get doubled() { return this.count * 2; }
293
294  @action()
295  increment() { this.count += 1; }
296
297  @action()
298  rename() { this.label = "done"; }
299
300  @effect()
301  sync() { console.log(this.doubled); console.log(this.label); }
302
303  @effect()
304  ready() { console.log("ready"); }
305
306  @effect()
307  invalid() { this.count = 0; }
308}
309"#,
310        );
311        let model = build_application_semantic_model(&parsed);
312        let registry = build_runtime_effect_registry(
313            &model,
314            &optimize_effect_ir(&lower_components_to_ir(&model)).output,
315        );
316        let plan = build_effect_resume_plan(&model, &registry);
317        let sync = model.components[0].id.effect("sync");
318        let ready = model.components[0].id.effect("ready");
319        let invalid = model.components[0].id.effect("invalid");
320        let sync_record = plan.record(&sync).expect("sync resume record");
321        let ready_record = plan.record(&ready).expect("ready resume record");
322
323        assert_eq!(plan.records.len(), 2);
324        assert_eq!(
325            sync_record.initial_status,
326            Some(EffectActivationStatus::Pending)
327        );
328        assert!(sync_record.initial_plan_membership.is_some());
329        assert_eq!(sync_record.action_batches.len(), 2);
330        assert!(ready_record.activation_slot.is_some());
331        assert!(ready_record.action_batches.is_empty());
332        assert_ne!(sync_record.activation_slot, ready_record.activation_slot);
333        assert!(plan.record(&invalid).is_none());
334        assert!(validate_effect_resume_plan(&model, &registry, &plan).is_empty());
335
336        let repeated = build_effect_resume_plan(&model, &registry);
337        assert_eq!(plan, repeated);
338
339        let mut malformed = plan.clone();
340        let sync_index = malformed
341            .records
342            .iter()
343            .position(|record| record.effect == sync)
344            .expect("sync record index");
345        let ready_index = malformed
346            .records
347            .iter()
348            .position(|record| record.effect == ready)
349            .expect("ready record index");
350        malformed.records[sync_index].runtime_function = ready.clone();
351        malformed.records[sync_index]
352            .action_batches
353            .push(model.components[0].id.action_batch("missing"));
354        malformed.records[ready_index].activation_slot =
355            malformed.records[sync_index].activation_slot.clone();
356        let codes = validate_effect_resume_plan(&model, &registry, &malformed)
357            .into_iter()
358            .map(|diagnostic| diagnostic.code)
359            .collect::<Vec<_>>();
360        assert!(codes.contains(&"PSRSM1105".to_string()));
361        assert!(codes.contains(&"PSRSM1110".to_string()));
362        assert!(codes.contains(&"PSRSM1111".to_string()));
363    }
364}