1use crate::{
7 db::{
8 codec::cursor::encode_cursor,
9 query::plan::AccessPlannedQuery,
10 query::{explain::ExplainPlan, fingerprint::hash_parts},
11 },
12 traits::FieldValue,
13};
14use sha2::{Digest, Sha256};
15
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct PlanFingerprint([u8; 32]);
24
25impl PlanFingerprint {
26 #[must_use]
27 pub fn as_hex(&self) -> String {
28 encode_cursor(&self.0)
29 }
30}
31
32impl std::fmt::Display for PlanFingerprint {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.write_str(&self.as_hex())
35 }
36}
37
38impl<K> AccessPlannedQuery<K>
39where
40 K: FieldValue,
41{
42 #[must_use]
44 #[cfg(test)]
45 pub(crate) fn fingerprint(&self) -> PlanFingerprint {
46 let explain = self.explain();
47 let projection = self.projection_spec_for_identity();
48 let mut hasher = Sha256::new();
49 hasher.update(b"planfp:v2");
50 hash_parts::hash_explain_plan_profile_with_projection(
51 &mut hasher,
52 &explain,
53 hash_parts::ExplainHashProfile::FingerprintV2,
54 &projection,
55 );
56 let digest = hasher.finalize();
57 let mut out = [0u8; 32];
58 out.copy_from_slice(&digest);
59
60 PlanFingerprint(out)
61 }
62}
63
64impl ExplainPlan {
65 #[must_use]
67 pub fn fingerprint(&self) -> PlanFingerprint {
68 let mut hasher = Sha256::new();
70 hasher.update(b"planfp:v2");
71 hash_parts::hash_explain_plan_profile(
72 &mut hasher,
73 self,
74 hash_parts::ExplainHashProfile::FingerprintV2,
75 );
76
77 let digest = hasher.finalize();
79 let mut out = [0u8; 32];
80 out.copy_from_slice(&digest);
81
82 PlanFingerprint(out)
83 }
84}
85
86#[cfg(test)]
91mod tests {
92 use std::ops::Bound;
93
94 use crate::db::access::AccessPath;
95 use crate::db::predicate::{ComparePredicate, MissingRowPolicy, Predicate};
96 use crate::db::query::explain::{ExplainGroupedStrategy, ExplainGrouping};
97 use crate::db::query::fingerprint::hash_parts;
98 use crate::db::query::intent::{KeyAccess, build_access_plan_from_keys};
99 use crate::db::query::plan::expr::{
100 Alias, BinaryOp, Expr, FieldId, ProjectionField, ProjectionSpec,
101 };
102 use crate::db::query::plan::{
103 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
104 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, LoadSpec, LogicalPlan, PageSpec,
105 QueryMode,
106 };
107 use crate::db::query::{builder::field::FieldRef, builder::sum};
108 use crate::model::index::IndexModel;
109 use crate::types::{Decimal, Ulid};
110 use crate::value::Value;
111 use sha2::{Digest, Sha256};
112
113 fn fingerprint_with_projection(
114 plan: &AccessPlannedQuery<Value>,
115 projection: &ProjectionSpec,
116 ) -> super::PlanFingerprint {
117 let explain = plan.explain();
118 let mut hasher = Sha256::new();
119 hasher.update(b"planfp:v2");
120 hash_parts::hash_explain_plan_profile_with_projection(
121 &mut hasher,
122 &explain,
123 hash_parts::ExplainHashProfile::FingerprintV2,
124 projection,
125 );
126 let digest = hasher.finalize();
127 let mut out = [0u8; 32];
128 out.copy_from_slice(&digest);
129
130 super::PlanFingerprint(out)
131 }
132
133 fn full_scan_query() -> AccessPlannedQuery<Value> {
134 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
135 }
136
137 fn index_prefix_query(index: IndexModel, values: Vec<Value>) -> AccessPlannedQuery<Value> {
138 AccessPlannedQuery::new(
139 AccessPath::IndexPrefix { index, values },
140 MissingRowPolicy::Ignore,
141 )
142 }
143
144 fn index_range_query(
145 index: IndexModel,
146 prefix: Vec<Value>,
147 lower: Bound<Value>,
148 upper: Bound<Value>,
149 ) -> AccessPlannedQuery<Value> {
150 AccessPlannedQuery::new(
151 AccessPath::index_range(index, prefix, lower, upper),
152 MissingRowPolicy::Ignore,
153 )
154 }
155
156 fn grouped_explain_with_fixed_shape() -> crate::db::query::explain::ExplainPlan {
157 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
158 .into_grouped(GroupSpec {
159 group_fields: vec![FieldSlot::from_parts_for_test(1, "rank")],
160 aggregates: vec![GroupAggregateSpec {
161 kind: AggregateKind::Count,
162 target_field: None,
163 distinct: false,
164 }],
165 execution: GroupedExecutionConfig::with_hard_limits(64, 4096),
166 })
167 .explain()
168 }
169
170 #[test]
171 fn fingerprint_is_deterministic_for_equivalent_predicates() {
172 let id = Ulid::default();
173
174 let predicate_a = Predicate::And(vec![
175 FieldRef::new("id").eq(id),
176 FieldRef::new("other").eq(Value::Text("x".to_string())),
177 ]);
178 let predicate_b = Predicate::And(vec![
179 FieldRef::new("other").eq(Value::Text("x".to_string())),
180 FieldRef::new("id").eq(id),
181 ]);
182
183 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
184 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
185
186 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
187 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
188
189 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
190 }
191
192 #[test]
193 fn fingerprint_and_signature_are_stable_for_reordered_and_non_canonical_map_predicates() {
194 let map_a = Value::Map(vec![
195 (Value::Text("z".to_string()), Value::Int(9)),
196 (Value::Text("a".to_string()), Value::Int(1)),
197 ]);
198 let map_b = Value::Map(vec![
199 (Value::Text("a".to_string()), Value::Int(1)),
200 (Value::Text("z".to_string()), Value::Int(9)),
201 ]);
202
203 let predicate_a = Predicate::And(vec![
204 FieldRef::new("other").eq(Value::Text("x".to_string())),
205 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_a)),
206 ]);
207 let predicate_b = Predicate::And(vec![
208 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_b)),
209 FieldRef::new("other").eq(Value::Text("x".to_string())),
210 ]);
211
212 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
213 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
214
215 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
216 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
217
218 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
219 assert_eq!(
220 plan_a.continuation_signature("tests::Entity"),
221 plan_b.continuation_signature("tests::Entity")
222 );
223 }
224
225 #[test]
226 fn fingerprint_is_deterministic_for_by_keys() {
227 let a = Ulid::from_u128(1);
228 let b = Ulid::from_u128(2);
229
230 let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
231 let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
232
233 let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
234 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
235 mode: QueryMode::Load(LoadSpec::new()),
236 predicate: None,
237 order: None,
238 distinct: false,
239 delete_limit: None,
240 page: None,
241 consistency: MissingRowPolicy::Ignore,
242 }),
243 access: access_a,
244 };
245 let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
246 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
247 mode: QueryMode::Load(LoadSpec::new()),
248 predicate: None,
249 order: None,
250 distinct: false,
251 delete_limit: None,
252 page: None,
253 consistency: MissingRowPolicy::Ignore,
254 }),
255 access: access_b,
256 };
257
258 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
259 }
260
261 #[test]
262 fn fingerprint_changes_with_index_choice() {
263 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
264 const INDEX_A: IndexModel = IndexModel::new(
265 "fingerprint::idx_a",
266 "fingerprint::store",
267 &INDEX_FIELDS,
268 false,
269 );
270 const INDEX_B: IndexModel = IndexModel::new(
271 "fingerprint::idx_b",
272 "fingerprint::store",
273 &INDEX_FIELDS,
274 false,
275 );
276
277 let plan_a: AccessPlannedQuery<Value> =
278 index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
279 let plan_b: AccessPlannedQuery<Value> =
280 index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
281
282 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
283 }
284
285 #[test]
286 fn fingerprint_changes_with_pagination() {
287 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
288 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
289 plan_a.scalar_plan_mut().page = Some(PageSpec {
290 limit: Some(10),
291 offset: 0,
292 });
293 plan_b.scalar_plan_mut().page = Some(PageSpec {
294 limit: Some(10),
295 offset: 1,
296 });
297
298 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
299 }
300
301 #[test]
302 fn fingerprint_changes_with_delete_limit() {
303 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
304 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
305 plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
306 plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
307 plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
308 plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
309
310 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
311 }
312
313 #[test]
314 fn fingerprint_changes_with_distinct_flag() {
315 let plan_a: AccessPlannedQuery<Value> = full_scan_query();
316 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
317 plan_b.scalar_plan_mut().distinct = true;
318
319 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
320 }
321
322 #[test]
323 fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
324 let plan: AccessPlannedQuery<Value> = full_scan_query();
325 let numeric_projection =
326 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
327 expr: Expr::Binary {
328 op: crate::db::query::plan::expr::BinaryOp::Add,
329 left: Box::new(Expr::Field(FieldId::new("rank"))),
330 right: Box::new(Expr::Literal(Value::Int(1))),
331 },
332 alias: None,
333 }]);
334 let alias_only_numeric_projection =
335 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
336 expr: Expr::Alias {
337 expr: Box::new(Expr::Binary {
338 op: crate::db::query::plan::expr::BinaryOp::Add,
339 left: Box::new(Expr::Field(FieldId::new("rank"))),
340 right: Box::new(Expr::Literal(Value::Int(1))),
341 }),
342 name: Alias::new("rank_plus_one_expr"),
343 },
344 alias: Some(Alias::new("rank_plus_one")),
345 }]);
346
347 let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
348 let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
349
350 assert_eq!(
351 semantic_fingerprint, alias_fingerprint,
352 "numeric projection alias wrappers must not affect fingerprint identity",
353 );
354 }
355
356 #[test]
357 fn fingerprint_numeric_projection_semantic_change_invalidates() {
358 let plan: AccessPlannedQuery<Value> = full_scan_query();
359 let projection_add_one =
360 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
361 expr: Expr::Binary {
362 op: crate::db::query::plan::expr::BinaryOp::Add,
363 left: Box::new(Expr::Field(FieldId::new("rank"))),
364 right: Box::new(Expr::Literal(Value::Int(1))),
365 },
366 alias: None,
367 }]);
368 let projection_mul_one =
369 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
370 expr: Expr::Binary {
371 op: crate::db::query::plan::expr::BinaryOp::Mul,
372 left: Box::new(Expr::Field(FieldId::new("rank"))),
373 right: Box::new(Expr::Literal(Value::Int(1))),
374 },
375 alias: None,
376 }]);
377
378 let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
379 let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
380
381 assert_ne!(
382 add_fingerprint, mul_fingerprint,
383 "numeric projection semantic changes must invalidate fingerprint identity",
384 );
385 }
386
387 #[test]
388 fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
389 let plan: AccessPlannedQuery<Value> = full_scan_query();
390 let decimal_one_scale_1 =
391 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
392 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
393 alias: None,
394 }]);
395 let decimal_one_scale_2 =
396 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
397 expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
398 alias: None,
399 }]);
400
401 assert_eq!(
402 fingerprint_with_projection(&plan, &decimal_one_scale_1),
403 fingerprint_with_projection(&plan, &decimal_one_scale_2),
404 "decimal scale-only literal changes must not fragment fingerprint identity",
405 );
406 }
407
408 #[test]
409 fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
410 let plan: AccessPlannedQuery<Value> = full_scan_query();
411 let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
412 expr: Expr::Literal(Value::Int(1)),
413 alias: None,
414 }]);
415 let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
416 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
417 alias: None,
418 }]);
419
420 assert_ne!(
421 fingerprint_with_projection(&plan, &int_literal),
422 fingerprint_with_projection(&plan, &decimal_literal),
423 "top-level literal subtype remains observable and identity-significant",
424 );
425 }
426
427 #[test]
428 fn fingerprint_numeric_promotion_paths_do_not_fragment() {
429 let plan: AccessPlannedQuery<Value> = full_scan_query();
430 let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
431 expr: Expr::Binary {
432 op: BinaryOp::Add,
433 left: Box::new(Expr::Literal(Value::Int(1))),
434 right: Box::new(Expr::Literal(Value::Int(2))),
435 },
436 alias: None,
437 }]);
438 let int_plus_decimal =
439 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
440 expr: Expr::Binary {
441 op: BinaryOp::Add,
442 left: Box::new(Expr::Literal(Value::Int(1))),
443 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
444 },
445 alias: None,
446 }]);
447 let decimal_plus_int =
448 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
449 expr: Expr::Binary {
450 op: BinaryOp::Add,
451 left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
452 right: Box::new(Expr::Literal(Value::Int(2))),
453 },
454 alias: None,
455 }]);
456
457 let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
458 let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
459 let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
460
461 assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
462 assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
463 }
464
465 #[test]
466 fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
467 let plan: AccessPlannedQuery<Value> = full_scan_query();
468 let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
469 expr: Expr::Binary {
470 op: BinaryOp::Add,
471 left: Box::new(Expr::Field(FieldId::new("rank"))),
472 right: Box::new(Expr::Field(FieldId::new("score"))),
473 },
474 alias: None,
475 }]);
476 let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
477 expr: Expr::Binary {
478 op: BinaryOp::Add,
479 left: Box::new(Expr::Field(FieldId::new("score"))),
480 right: Box::new(Expr::Field(FieldId::new("rank"))),
481 },
482 alias: None,
483 }]);
484
485 assert_ne!(
486 fingerprint_with_projection(&plan, &rank_plus_score),
487 fingerprint_with_projection(&plan, &score_plus_rank),
488 "fingerprint preserves AST operand order for commutative operators in v2",
489 );
490 }
491
492 #[test]
493 fn fingerprint_aggregate_numeric_target_field_remains_significant() {
494 let plan: AccessPlannedQuery<Value> = full_scan_query();
495 let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
496 expr: Expr::Aggregate(sum("rank")),
497 alias: None,
498 }]);
499 let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
500 expr: Expr::Aggregate(sum("score")),
501 alias: None,
502 }]);
503
504 assert_ne!(
505 fingerprint_with_projection(&plan, &sum_rank),
506 fingerprint_with_projection(&plan, &sum_score),
507 "aggregate target field changes must invalidate fingerprint identity",
508 );
509 }
510
511 #[test]
512 fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
513 let plan: AccessPlannedQuery<Value> = full_scan_query();
514 let sum_distinct_plus_int_zero =
515 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
516 expr: Expr::Binary {
517 op: BinaryOp::Add,
518 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
519 right: Box::new(Expr::Literal(Value::Int(0))),
520 },
521 alias: None,
522 }]);
523 let sum_distinct_plus_decimal_zero =
524 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
525 expr: Expr::Binary {
526 op: BinaryOp::Add,
527 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
528 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
529 },
530 alias: None,
531 }]);
532
533 assert_eq!(
534 fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
535 fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
536 "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
537 );
538 }
539
540 #[test]
541 fn fingerprint_is_stable_for_full_scan() {
542 let plan: AccessPlannedQuery<Value> = full_scan_query();
543 let fingerprint_a = plan.fingerprint();
544 let fingerprint_b = plan.fingerprint();
545 assert_eq!(fingerprint_a, fingerprint_b);
546 }
547
548 #[test]
549 fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
550 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
551 const INDEX: IndexModel = IndexModel::new(
552 "fingerprint::group_rank",
553 "fingerprint::store",
554 &INDEX_FIELDS,
555 false,
556 );
557
558 let plan_a: AccessPlannedQuery<Value> = index_range_query(
559 INDEX,
560 vec![Value::Uint(7)],
561 Bound::Included(Value::Uint(100)),
562 Bound::Excluded(Value::Uint(200)),
563 );
564 let plan_b: AccessPlannedQuery<Value> = index_range_query(
565 INDEX,
566 vec![Value::Uint(7)],
567 Bound::Included(Value::Uint(100)),
568 Bound::Excluded(Value::Uint(200)),
569 );
570
571 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
572 }
573
574 #[test]
575 fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
576 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
577 const INDEX: IndexModel = IndexModel::new(
578 "fingerprint::group_rank",
579 "fingerprint::store",
580 &INDEX_FIELDS,
581 false,
582 );
583
584 let plan_included: AccessPlannedQuery<Value> = index_range_query(
585 INDEX,
586 vec![Value::Uint(7)],
587 Bound::Included(Value::Uint(100)),
588 Bound::Excluded(Value::Uint(200)),
589 );
590 let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
591 INDEX,
592 vec![Value::Uint(7)],
593 Bound::Excluded(Value::Uint(100)),
594 Bound::Excluded(Value::Uint(200)),
595 );
596
597 assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
598 }
599
600 #[test]
601 fn fingerprint_changes_when_index_range_bound_value_changes() {
602 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
603 const INDEX: IndexModel = IndexModel::new(
604 "fingerprint::group_rank",
605 "fingerprint::store",
606 &INDEX_FIELDS,
607 false,
608 );
609
610 let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
611 INDEX,
612 vec![Value::Uint(7)],
613 Bound::Included(Value::Uint(100)),
614 Bound::Excluded(Value::Uint(200)),
615 );
616 let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
617 INDEX,
618 vec![Value::Uint(7)],
619 Bound::Included(Value::Uint(101)),
620 Bound::Excluded(Value::Uint(200)),
621 );
622
623 assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
624 }
625
626 #[test]
627 fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
628 let mut hash_strategy = grouped_explain_with_fixed_shape();
629 let mut ordered_strategy = hash_strategy.clone();
630
631 let ExplainGrouping::Grouped {
632 strategy: hash_value,
633 ..
634 } = &mut hash_strategy.grouping
635 else {
636 panic!("grouped explain fixture must produce grouped explain shape");
637 };
638 *hash_value = ExplainGroupedStrategy::HashGroup;
639 let ExplainGrouping::Grouped {
640 strategy: ordered_value,
641 ..
642 } = &mut ordered_strategy.grouping
643 else {
644 panic!("grouped explain fixture must produce grouped explain shape");
645 };
646 *ordered_value = ExplainGroupedStrategy::OrderedGroup;
647
648 assert_eq!(
649 hash_strategy.fingerprint(),
650 ordered_strategy.fingerprint(),
651 "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
652 );
653 }
654}