Skip to main content

ferrum_interfaces/vnext/operation/
identity.rs

1use serde::{ser::SerializeSeq, Serialize, Serializer};
2use std::collections::BTreeSet;
3use std::sync::{Arc, OnceLock};
4
5use super::super::{
6    BatchInvocationId, BatchStepId, DeviceId, ExecutionIdentityEnvelope, ExecutionLaneId, NodeId,
7    OperationId, ParticipantNodeKey, PlanHash, PlanId, ProviderId, VNextError,
8};
9use super::compiled_identity::{
10    CompiledSubmissionWaveIdentity, SubmissionWaveParticipantIdentitySeed,
11};
12use super::foundation::{canonical_operation_fingerprint, canonical_sha256, invalid_operation};
13use super::ProviderExecutionSemantics;
14
15#[derive(Debug, PartialEq, Eq, Serialize)]
16struct BatchOperationParticipantIdentityData {
17    participant_index: u32,
18    node_key: ParticipantNodeKey,
19    identity: ExecutionIdentityEnvelope,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct BatchOperationParticipantIdentity {
24    data: Arc<BatchOperationParticipantIdentityData>,
25}
26
27impl Serialize for BatchOperationParticipantIdentity {
28    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
29    where
30        S: Serializer,
31    {
32        self.data.as_ref().serialize(serializer)
33    }
34}
35
36impl BatchOperationParticipantIdentity {
37    pub(super) fn new(
38        participant_index: u32,
39        node_key: ParticipantNodeKey,
40        identity: ExecutionIdentityEnvelope,
41    ) -> Self {
42        Self {
43            data: Arc::new(BatchOperationParticipantIdentityData {
44                participant_index,
45                node_key,
46                identity,
47            }),
48        }
49    }
50
51    pub fn participant_index(&self) -> u32 {
52        self.data.participant_index
53    }
54
55    pub fn node_key(&self) -> &ParticipantNodeKey {
56        &self.data.node_key
57    }
58
59    pub fn identity(&self) -> &ExecutionIdentityEnvelope {
60        &self.data.identity
61    }
62}
63
64/// One immutable-plan node inside a physical command batch. Participant
65/// identities stay node-local even when several nodes share one submission.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct BatchOperationNodeIdentity {
68    node_index: u32,
69    node_id: NodeId,
70    operation_id: OperationId,
71    provider_id: ProviderId,
72    provider_implementation_fingerprint: String,
73    provider_execution_semantics: ProviderExecutionSemantics,
74    work_shape_fingerprint: String,
75    participants: Vec<BatchOperationParticipantIdentity>,
76    fingerprint: String,
77}
78
79impl BatchOperationNodeIdentity {
80    pub(super) fn from_validated(
81        node_index: u32,
82        node_id: NodeId,
83        operation_id: OperationId,
84        provider_id: ProviderId,
85        provider_implementation_fingerprint: String,
86        provider_execution_semantics: ProviderExecutionSemantics,
87        work_shape_fingerprint: String,
88        participants: Vec<BatchOperationParticipantIdentity>,
89    ) -> Result<Self, VNextError> {
90        let participant_start = participants
91            .first()
92            .map(BatchOperationParticipantIdentity::participant_index);
93        if participants.is_empty()
94            || participants.iter().enumerate().any(|(index, participant)| {
95                participant_start.and_then(|start| start.checked_add(index as u32))
96                    != Some(participant.participant_index())
97                    || participant.node_key().node_id() != &node_id
98                    || participant.identity().parts().frame_id
99                        != Some(participant.node_key().frame_id())
100                    || participant.identity().parts().node_id.as_ref() != Some(&node_id)
101                    || participant.identity().parts().operation_id.as_ref() != Some(&operation_id)
102                    || participant.identity().parts().provider_id.as_ref() != Some(&provider_id)
103            })
104            || participants
105                .windows(2)
106                .any(|pair| pair[0].node_key() >= pair[1].node_key())
107            || !canonical_sha256(&provider_implementation_fingerprint)
108            || !canonical_sha256(&work_shape_fingerprint)
109        {
110            return Err(invalid_operation(
111                "batch node identity is empty, non-canonical, or differs from its participant projections",
112            ));
113        }
114        #[derive(Serialize)]
115        struct FingerprintInput<'a> {
116            domain: &'static str,
117            node_index: u32,
118            node_id: &'a NodeId,
119            operation_id: &'a OperationId,
120            provider_id: &'a ProviderId,
121            provider_implementation_fingerprint: &'a str,
122            provider_execution_semantics: ProviderExecutionSemantics,
123            work_shape_fingerprint: &'a str,
124            participants: &'a [BatchOperationParticipantIdentity],
125        }
126        let fingerprint = canonical_operation_fingerprint(
127            &FingerprintInput {
128                domain: "ferrum.runtime-vnext.batch-operation-node-identity.v2",
129                node_index,
130                node_id: &node_id,
131                operation_id: &operation_id,
132                provider_id: &provider_id,
133                provider_implementation_fingerprint: &provider_implementation_fingerprint,
134                provider_execution_semantics,
135                work_shape_fingerprint: &work_shape_fingerprint,
136                participants: &participants,
137            },
138            "batch node identity encode failed",
139        )?;
140        Ok(Self {
141            node_index,
142            node_id,
143            operation_id,
144            provider_id,
145            provider_implementation_fingerprint,
146            provider_execution_semantics,
147            work_shape_fingerprint,
148            participants,
149            fingerprint,
150        })
151    }
152
153    pub const fn node_index(&self) -> u32 {
154        self.node_index
155    }
156
157    pub fn node_id(&self) -> &NodeId {
158        &self.node_id
159    }
160
161    pub fn operation_id(&self) -> &OperationId {
162        &self.operation_id
163    }
164
165    pub fn provider_id(&self) -> &ProviderId {
166        &self.provider_id
167    }
168
169    pub fn provider_implementation_fingerprint(&self) -> &str {
170        &self.provider_implementation_fingerprint
171    }
172
173    pub const fn provider_execution_semantics(&self) -> ProviderExecutionSemantics {
174        self.provider_execution_semantics
175    }
176
177    pub fn work_shape_fingerprint(&self) -> &str {
178        &self.work_shape_fingerprint
179    }
180
181    pub fn participants(&self) -> &[BatchOperationParticipantIdentity] {
182        &self.participants
183    }
184
185    pub fn fingerprint(&self) -> &str {
186        &self.fingerprint
187    }
188
189    pub(super) fn contains_identity(&self, identity: &ExecutionIdentityEnvelope) -> bool {
190        self.participants
191            .iter()
192            .any(|participant| participant.identity() == identity)
193    }
194}
195
196/// One physical command-batch attempt identity. It may contain one operation
197/// or the entire immutable-plan wave, but it always maps to one submit/fence.
198#[derive(Debug)]
199struct BatchOperationIdentityData {
200    batch_step_id: BatchStepId,
201    batch_invocation_id: BatchInvocationId,
202    plan_id: PlanId,
203    plan_hash: PlanHash,
204    device_id: DeviceId,
205    runtime_implementation_fingerprint: String,
206    lane_id: ExecutionLaneId,
207    claimed_backing_fingerprint: String,
208    nodes: OnceLock<Vec<BatchOperationNodeIdentity>>,
209    participants: OnceLock<Vec<BatchOperationParticipantIdentity>>,
210    deferred_recipe: Option<DeferredBatchOperationIdentityRecipe>,
211    fingerprint: String,
212}
213
214#[derive(Debug, Clone)]
215pub struct BatchOperationIdentity {
216    data: Arc<BatchOperationIdentityData>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
220pub struct BatchOperationIdentityMaterializationSnapshot {
221    logical_nodes: u32,
222    materialized_nodes: u32,
223    full_participant_projection: bool,
224}
225
226impl BatchOperationIdentityMaterializationSnapshot {
227    pub const fn logical_nodes(self) -> u32 {
228        self.logical_nodes
229    }
230
231    pub const fn materialized_nodes(self) -> u32 {
232        self.materialized_nodes
233    }
234
235    pub const fn full_participant_projection(self) -> bool {
236        self.full_participant_projection
237    }
238}
239
240impl PartialEq for BatchOperationIdentity {
241    fn eq(&self, other: &Self) -> bool {
242        self.data.batch_step_id == other.data.batch_step_id
243            && self.data.batch_invocation_id == other.data.batch_invocation_id
244            && self.data.plan_id == other.data.plan_id
245            && self.data.plan_hash == other.data.plan_hash
246            && self.data.device_id == other.data.device_id
247            && self.data.runtime_implementation_fingerprint
248                == other.data.runtime_implementation_fingerprint
249            && self.data.lane_id == other.data.lane_id
250            && self.data.claimed_backing_fingerprint == other.data.claimed_backing_fingerprint
251            && self.data.fingerprint == other.data.fingerprint
252    }
253}
254
255impl Eq for BatchOperationIdentity {}
256
257impl Serialize for BatchOperationIdentity {
258    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
259    where
260        S: Serializer,
261    {
262        #[derive(Serialize)]
263        struct Wire<'a> {
264            batch_step_id: BatchStepId,
265            batch_invocation_id: BatchInvocationId,
266            plan_id: &'a PlanId,
267            plan_hash: &'a PlanHash,
268            device_id: &'a DeviceId,
269            runtime_implementation_fingerprint: &'a str,
270            lane_id: ExecutionLaneId,
271            claimed_backing_fingerprint: &'a str,
272            nodes: &'a [BatchOperationNodeIdentity],
273            participants: &'a [BatchOperationParticipantIdentity],
274            fingerprint: &'a str,
275        }
276
277        Wire {
278            batch_step_id: self.data.batch_step_id,
279            batch_invocation_id: self.data.batch_invocation_id,
280            plan_id: &self.data.plan_id,
281            plan_hash: &self.data.plan_hash,
282            device_id: &self.data.device_id,
283            runtime_implementation_fingerprint: &self.data.runtime_implementation_fingerprint,
284            lane_id: self.data.lane_id,
285            claimed_backing_fingerprint: &self.data.claimed_backing_fingerprint,
286            nodes: self.nodes(),
287            participants: self.participants(),
288            fingerprint: &self.data.fingerprint,
289        }
290        .serialize(serializer)
291    }
292}
293
294struct BatchOperationNodeFingerprints<'a>(&'a [BatchOperationNodeIdentity]);
295
296impl Serialize for BatchOperationNodeFingerprints<'_> {
297    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
298    where
299        S: Serializer,
300    {
301        let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
302        for node in self.0 {
303            sequence.serialize_element(node.fingerprint())?;
304        }
305        sequence.end()
306    }
307}
308
309impl BatchOperationIdentity {
310    #[allow(clippy::too_many_arguments)]
311    fn from_deferred_validated(
312        batch_step_id: BatchStepId,
313        batch_invocation_id: BatchInvocationId,
314        plan_id: PlanId,
315        plan_hash: PlanHash,
316        device_id: DeviceId,
317        runtime_implementation_fingerprint: String,
318        lane_id: ExecutionLaneId,
319        claimed_backing_fingerprint: String,
320        deferred_recipe: DeferredBatchOperationIdentityRecipe,
321        fingerprint: String,
322    ) -> Self {
323        Self {
324            data: Arc::new(BatchOperationIdentityData {
325                batch_step_id,
326                batch_invocation_id,
327                plan_id,
328                plan_hash,
329                device_id,
330                runtime_implementation_fingerprint,
331                lane_id,
332                claimed_backing_fingerprint,
333                nodes: OnceLock::new(),
334                participants: OnceLock::new(),
335                deferred_recipe: Some(deferred_recipe),
336                fingerprint,
337            }),
338        }
339    }
340
341    #[allow(clippy::too_many_arguments)]
342    pub(super) fn from_validated(
343        batch_step_id: BatchStepId,
344        batch_invocation_id: BatchInvocationId,
345        plan_id: PlanId,
346        plan_hash: PlanHash,
347        device_id: DeviceId,
348        runtime_implementation_fingerprint: String,
349        lane_id: ExecutionLaneId,
350        claimed_backing_fingerprint: String,
351        nodes: Vec<BatchOperationNodeIdentity>,
352    ) -> Result<Self, VNextError> {
353        if nodes.is_empty()
354            || nodes.iter().enumerate().any(|(index, node)| {
355                node.node_index as usize != index
356                    || node.participants.iter().any(|participant| {
357                        participant.identity().parts().plan_id.as_ref() != Some(&plan_id)
358                            || participant.identity().parts().plan_hash.as_ref() != Some(&plan_hash)
359                            || participant.identity().parts().device_id.as_ref() != Some(&device_id)
360                            || participant
361                                .identity()
362                                .parts()
363                                .runtime_implementation_fingerprint
364                                .as_deref()
365                                != Some(runtime_implementation_fingerprint.as_str())
366                    })
367            })
368            || nodes
369                .iter()
370                .map(BatchOperationNodeIdentity::node_id)
371                .collect::<BTreeSet<_>>()
372                .len()
373                != nodes.len()
374            || !canonical_sha256(&runtime_implementation_fingerprint)
375            || !canonical_sha256(&claimed_backing_fingerprint)
376        {
377            return Err(invalid_operation(
378                "physical batch identity is empty, non-canonical, or differs from its plan/runtime projections",
379            ));
380        }
381        let participants = nodes
382            .iter()
383            .flat_map(|node| node.participants.iter().cloned())
384            .collect::<Vec<_>>();
385        if participants
386            .iter()
387            .enumerate()
388            .any(|(index, participant)| participant.participant_index() as usize != index)
389        {
390            return Err(invalid_operation(
391                "physical batch participant indices are not globally contiguous",
392            ));
393        }
394        #[derive(Serialize)]
395        struct FingerprintInput<'a> {
396            domain: &'static str,
397            batch_step_id: BatchStepId,
398            batch_invocation_id: BatchInvocationId,
399            plan_id: &'a PlanId,
400            plan_hash: &'a PlanHash,
401            device_id: &'a DeviceId,
402            runtime_implementation_fingerprint: &'a str,
403            lane_id: ExecutionLaneId,
404            claimed_backing_fingerprint: &'a str,
405            node_fingerprints: BatchOperationNodeFingerprints<'a>,
406        }
407        let fingerprint = canonical_operation_fingerprint(
408            &FingerprintInput {
409                domain: "ferrum.runtime-vnext.physical-command-batch-identity.v2",
410                batch_step_id,
411                batch_invocation_id,
412                plan_id: &plan_id,
413                plan_hash: &plan_hash,
414                device_id: &device_id,
415                runtime_implementation_fingerprint: &runtime_implementation_fingerprint,
416                lane_id,
417                claimed_backing_fingerprint: &claimed_backing_fingerprint,
418                node_fingerprints: BatchOperationNodeFingerprints(&nodes),
419            },
420            "physical batch identity encode failed",
421        )?;
422        Ok(Self {
423            data: Arc::new(BatchOperationIdentityData {
424                batch_step_id,
425                batch_invocation_id,
426                plan_id,
427                plan_hash,
428                device_id,
429                runtime_implementation_fingerprint,
430                lane_id,
431                claimed_backing_fingerprint,
432                nodes: OnceLock::from(nodes),
433                participants: OnceLock::from(participants),
434                deferred_recipe: None,
435                fingerprint,
436            }),
437        })
438    }
439
440    pub fn batch_step_id(&self) -> BatchStepId {
441        self.data.batch_step_id
442    }
443
444    pub fn batch_invocation_id(&self) -> BatchInvocationId {
445        self.data.batch_invocation_id
446    }
447
448    pub fn plan_id(&self) -> &PlanId {
449        &self.data.plan_id
450    }
451
452    pub fn plan_hash(&self) -> &PlanHash {
453        &self.data.plan_hash
454    }
455
456    pub fn device_id(&self) -> &DeviceId {
457        &self.data.device_id
458    }
459
460    pub fn runtime_implementation_fingerprint(&self) -> &str {
461        &self.data.runtime_implementation_fingerprint
462    }
463
464    pub fn lane_id(&self) -> ExecutionLaneId {
465        self.data.lane_id
466    }
467
468    pub fn claimed_backing_fingerprint(&self) -> &str {
469        &self.data.claimed_backing_fingerprint
470    }
471
472    pub fn node_count(&self) -> usize {
473        self.data.nodes.get().map_or_else(
474            || {
475                self.data
476                    .deferred_recipe
477                    .as_ref()
478                    .map_or(0, |recipe| recipe.topology.node_count())
479            },
480            Vec::len,
481        )
482    }
483
484    fn materialized_node_count(&self) -> usize {
485        self.data.nodes.get().map_or_else(
486            || {
487                self.data.deferred_recipe.as_ref().map_or(0, |recipe| {
488                    recipe
489                        .node_identities
490                        .iter()
491                        .filter(|identity| identity.get().is_some())
492                        .count()
493                })
494            },
495            Vec::len,
496        )
497    }
498
499    pub fn materialization_snapshot(&self) -> BatchOperationIdentityMaterializationSnapshot {
500        BatchOperationIdentityMaterializationSnapshot {
501            logical_nodes: u32::try_from(self.node_count())
502                .expect("validated physical batch node count fits u32"),
503            materialized_nodes: u32::try_from(self.materialized_node_count())
504                .expect("materialized physical batch node count fits u32"),
505            full_participant_projection: self.data.participants.get().is_some(),
506        }
507    }
508
509    pub fn node_participant_count(&self, node_index: usize) -> Option<usize> {
510        if let Some(nodes) = self.data.nodes.get() {
511            return nodes.get(node_index).map(|node| node.participants().len());
512        }
513        let recipe = self.data.deferred_recipe.as_ref()?;
514        (node_index < recipe.topology.node_count()).then_some(recipe.participant_seeds.len())
515    }
516
517    pub fn node_id_at(&self, node_index: usize) -> Option<&NodeId> {
518        if let Some(nodes) = self.data.nodes.get() {
519            return nodes
520                .get(node_index)
521                .map(BatchOperationNodeIdentity::node_id);
522        }
523        self.data
524            .deferred_recipe
525            .as_ref()?
526            .topology
527            .node_id_at(node_index)
528    }
529
530    pub fn operation_id_at(&self, node_index: usize) -> Option<&OperationId> {
531        if let Some(nodes) = self.data.nodes.get() {
532            return nodes
533                .get(node_index)
534                .map(BatchOperationNodeIdentity::operation_id);
535        }
536        self.data
537            .deferred_recipe
538            .as_ref()?
539            .topology
540            .operation_id_at(node_index)
541    }
542
543    pub fn provider_id_at(&self, node_index: usize) -> Option<&ProviderId> {
544        if let Some(nodes) = self.data.nodes.get() {
545            return nodes
546                .get(node_index)
547                .map(BatchOperationNodeIdentity::provider_id);
548        }
549        self.data
550            .deferred_recipe
551            .as_ref()?
552            .topology
553            .provider_id_at(node_index)
554    }
555
556    pub fn work_shape_fingerprint_at(&self, node_index: usize) -> Option<&str> {
557        if let Some(nodes) = self.data.nodes.get() {
558            return nodes
559                .get(node_index)
560                .map(BatchOperationNodeIdentity::work_shape_fingerprint);
561        }
562        let recipe = self.data.deferred_recipe.as_ref()?;
563        (node_index < recipe.topology.node_count()).then_some(recipe.work_shape_fingerprint())
564    }
565
566    pub fn node_index(&self, node_id: &NodeId) -> Option<usize> {
567        if let Some(nodes) = self.data.nodes.get() {
568            return nodes.iter().position(|node| node.node_id() == node_id);
569        }
570        self.data
571            .deferred_recipe
572            .as_ref()?
573            .topology
574            .node_index(node_id)
575    }
576
577    pub(crate) fn materialize_node(
578        &self,
579        node_index: usize,
580    ) -> Result<&BatchOperationNodeIdentity, VNextError> {
581        if let Some(nodes) = self.data.nodes.get() {
582            return nodes
583                .get(node_index)
584                .ok_or_else(|| invalid_operation("physical batch node index is out of bounds"));
585        }
586        let recipe = self.data.deferred_recipe.as_ref().ok_or_else(|| {
587            invalid_operation("physical batch has neither materialized nodes nor a compiled recipe")
588        })?;
589        let slot = recipe.node_identities.get(node_index).ok_or_else(|| {
590            invalid_operation("compiled physical batch node index is out of bounds")
591        })?;
592        if let Some(identity) = slot.get() {
593            return Ok(identity);
594        }
595        let identity = recipe.materialize_node(node_index)?;
596        let _ = slot.set(identity);
597        slot.get().ok_or_else(|| {
598            invalid_operation("compiled physical batch node identity publication failed")
599        })
600    }
601
602    pub fn nodes(&self) -> &[BatchOperationNodeIdentity] {
603        self.data.nodes.get_or_init(|| {
604            (0..self.node_count())
605                .map(|node_index| {
606                    self.materialize_node(node_index)
607                        .expect("validated compiled physical batch node must materialize")
608                        .clone()
609                })
610                .collect()
611        })
612    }
613
614    pub fn single_node(&self) -> Option<&BatchOperationNodeIdentity> {
615        (self.node_count() == 1).then(|| {
616            self.materialize_node(0)
617                .expect("validated single-node physical batch must materialize")
618        })
619    }
620
621    pub fn participants(&self) -> &[BatchOperationParticipantIdentity] {
622        self.data.participants.get_or_init(|| {
623            self.nodes()
624                .iter()
625                .flat_map(|node| node.participants().iter().cloned())
626                .collect()
627        })
628    }
629
630    pub fn fingerprint(&self) -> &str {
631        &self.data.fingerprint
632    }
633
634    pub(super) fn contains_identity(&self, identity: &ExecutionIdentityEnvelope) -> bool {
635        self.participants()
636            .iter()
637            .any(|participant| participant.identity() == identity)
638    }
639}
640
641#[derive(Debug)]
642struct DeferredBatchOperationIdentityRecipe {
643    topology: CompiledSubmissionWaveIdentity,
644    work_shape_fingerprint: String,
645    participant_seeds: Vec<SubmissionWaveParticipantIdentitySeed>,
646    node_identities: Box<[OnceLock<BatchOperationNodeIdentity>]>,
647}
648
649impl DeferredBatchOperationIdentityRecipe {
650    fn work_shape_fingerprint(&self) -> &str {
651        &self.work_shape_fingerprint
652    }
653
654    fn materialize_node(
655        &self,
656        node_index: usize,
657    ) -> Result<BatchOperationNodeIdentity, VNextError> {
658        let node = self
659            .topology
660            .node_at(node_index)
661            .ok_or_else(|| invalid_operation("compiled wave node index is out of bounds"))?;
662        let participant_start = node_index
663            .checked_mul(self.participant_seeds.len())
664            .and_then(|value| u32::try_from(value).ok())
665            .ok_or_else(|| {
666                invalid_operation("compiled wave participant index space exceeds u32")
667            })?;
668        let participants = self
669            .participant_seeds
670            .iter()
671            .enumerate()
672            .map(|(local_index, seed)| {
673                let local_index = u32::try_from(local_index)
674                    .expect("compiled wave participant count was validated");
675                let frame = seed.frame();
676                let identity = seed
677                    .operation_identity(&self.topology, node_index)
678                    .ok_or_else(|| invalid_operation("compiled wave node identity disappeared"))?;
679                Ok(BatchOperationParticipantIdentity::new(
680                    participant_start
681                        .checked_add(local_index)
682                        .expect("compiled wave participant index was validated"),
683                    ParticipantNodeKey::new(
684                        frame.participant(),
685                        frame.frame_id(),
686                        node.node_id().clone(),
687                    ),
688                    identity,
689                ))
690            })
691            .collect::<Result<Vec<_>, VNextError>>()?;
692        BatchOperationNodeIdentity::from_validated(
693            node.node_index(),
694            node.node_id().clone(),
695            node.operation_id().clone(),
696            node.provider_id().clone(),
697            node.provider_implementation_fingerprint().to_owned(),
698            node.provider_execution_semantics(),
699            self.work_shape_fingerprint.clone(),
700            participants,
701        )
702    }
703}
704
705impl BatchOperationIdentity {
706    #[allow(clippy::too_many_arguments)]
707    pub(super) fn from_compiled_wave(
708        topology: CompiledSubmissionWaveIdentity,
709        batch_step_id: BatchStepId,
710        batch_invocation_id: BatchInvocationId,
711        claimed_backing_fingerprint: String,
712        work_shape_fingerprint: String,
713        participant_seeds: Vec<SubmissionWaveParticipantIdentitySeed>,
714    ) -> Result<Self, VNextError> {
715        let participant_count = participant_seeds.len();
716        let participant_projection_count = topology
717            .node_count()
718            .checked_mul(participant_count)
719            .and_then(|count| u32::try_from(count).ok());
720        if topology.node_count() == 0
721            || participant_count == 0
722            || participant_projection_count.is_none()
723            || participant_seeds.windows(2).any(|pair| {
724                let left = pair[0].frame().participant();
725                let right = pair[1].frame().participant();
726                (
727                    left.sequence_authority().sparse_id(),
728                    left.sequence_authority().generation(),
729                    left.request_authority().sparse_id(),
730                    left.request_authority().generation(),
731                ) >= (
732                    right.sequence_authority().sparse_id(),
733                    right.sequence_authority().generation(),
734                    right.request_authority().sparse_id(),
735                    right.request_authority().generation(),
736                )
737            })
738            || participant_seeds.iter().any(|seed| {
739                seed.runtime_implementation_fingerprint()
740                    != topology.runtime_implementation_fingerprint()
741            })
742            || !canonical_sha256(&claimed_backing_fingerprint)
743            || !canonical_sha256(&work_shape_fingerprint)
744        {
745            return Err(invalid_operation(
746                "compiled physical batch identity is empty, non-canonical, or exceeds its participant index space",
747            ));
748        }
749        #[derive(Serialize)]
750        struct FingerprintInput<'a> {
751            domain: &'static str,
752            batch_step_id: BatchStepId,
753            batch_invocation_id: BatchInvocationId,
754            topology_fingerprint: &'a str,
755            claimed_backing_fingerprint: &'a str,
756            work_shape_fingerprint: &'a str,
757            participant_seeds: &'a [SubmissionWaveParticipantIdentitySeed],
758        }
759        let fingerprint = canonical_operation_fingerprint(
760            &FingerprintInput {
761                domain: "ferrum.runtime-vnext.compiled-physical-command-batch-identity.v1",
762                batch_step_id,
763                batch_invocation_id,
764                topology_fingerprint: topology.fingerprint(),
765                claimed_backing_fingerprint: &claimed_backing_fingerprint,
766                work_shape_fingerprint: &work_shape_fingerprint,
767                participant_seeds: &participant_seeds,
768            },
769            "compiled physical batch identity encode failed",
770        )?;
771        let node_identities = std::iter::repeat_with(OnceLock::new)
772            .take(topology.node_count())
773            .collect::<Vec<_>>()
774            .into_boxed_slice();
775        let plan_id = topology.plan_id().clone();
776        let plan_hash = topology.plan_hash().clone();
777        let device_id = topology.device_id().clone();
778        let runtime_implementation_fingerprint =
779            topology.runtime_implementation_fingerprint().to_owned();
780        let lane_id = topology.lane_id();
781        Ok(Self::from_deferred_validated(
782            batch_step_id,
783            batch_invocation_id,
784            plan_id,
785            plan_hash,
786            device_id,
787            runtime_implementation_fingerprint,
788            lane_id,
789            claimed_backing_fingerprint,
790            DeferredBatchOperationIdentityRecipe {
791                topology,
792                work_shape_fingerprint,
793                participant_seeds,
794                node_identities,
795            },
796            fingerprint,
797        ))
798    }
799}
800
801#[cfg(test)]
802mod batch_operation_identity_fingerprint_tests {
803    use super::{
804        canonical_operation_fingerprint, BatchOperationNodeFingerprints,
805        BatchOperationNodeIdentity, NodeId, OperationId, ProviderExecutionSemantics, ProviderId,
806        Serialize,
807    };
808    use sha2::{Digest, Sha256};
809
810    fn fingerprint_node(index: u32, marker: char) -> BatchOperationNodeIdentity {
811        BatchOperationNodeIdentity {
812            node_index: index,
813            node_id: NodeId::new(format!("node.{index}")).unwrap(),
814            operation_id: OperationId::new(format!("operation.{index}")).unwrap(),
815            provider_id: ProviderId::new(format!("provider.{index}")).unwrap(),
816            provider_implementation_fingerprint: std::iter::repeat_n(marker, 64).collect(),
817            provider_execution_semantics: ProviderExecutionSemantics::bitwise_eager_and_replay(),
818            work_shape_fingerprint: std::iter::repeat_n(marker, 64).collect(),
819            participants: Vec::new(),
820            fingerprint: std::iter::repeat_n(marker, 64).collect(),
821        }
822    }
823
824    #[test]
825    fn streaming_fingerprint_matches_canonical_json_digest() {
826        #[derive(Serialize)]
827        struct Input<'a> {
828            domain: &'static str,
829            value: &'a str,
830        }
831
832        let input = Input {
833            domain: "ferrum.runtime-vnext.test",
834            value: "evidence",
835        };
836        let expected = format!("{:x}", Sha256::digest(serde_json::to_vec(&input).unwrap()));
837
838        assert_eq!(
839            canonical_operation_fingerprint(&input, "test fingerprint").unwrap(),
840            expected
841        );
842    }
843
844    #[test]
845    fn batch_fingerprint_projection_contains_only_validated_node_digests() {
846        let first = std::iter::repeat_n('a', 64).collect::<String>();
847        let second = std::iter::repeat_n('b', 64).collect::<String>();
848        let nodes = [fingerprint_node(0, 'a'), fingerprint_node(1, 'b')];
849
850        let encoded = serde_json::to_string(&BatchOperationNodeFingerprints(&nodes)).unwrap();
851
852        assert_eq!(encoded, format!("[\"{first}\",\"{second}\"]"));
853        assert!(!encoded.contains("node.0"));
854        assert!(!encoded.contains("operation.0"));
855        assert!(!encoded.contains("participants"));
856    }
857}