Skip to main content

icydb_core/db/query/fingerprint/
fingerprint.rs

1//! Module: query::fingerprint::fingerprint
2//! Responsibility: deterministic plan fingerprint derivation from explain models.
3//! Does not own: explain projection assembly or execution-plan compilation.
4//! Boundary: stable plan identity hash surface for diagnostics/caching.
5
6use 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///
17/// PlanFingerprint
18///
19/// Stable, deterministic fingerprint for logical plans.
20///
21
22#[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    /// Compute a stable fingerprint for this logical plan.
43    #[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    /// Compute a stable fingerprint for this explain plan.
66    #[must_use]
67    pub fn fingerprint(&self) -> PlanFingerprint {
68        // Phase 1: hash canonical explain fields under the current fingerprint profile.
69        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        // Phase 2: finalize into the fixed-width fingerprint payload.
78        let digest = hasher.finalize();
79        let mut out = [0u8; 32];
80        out.copy_from_slice(&digest);
81
82        PlanFingerprint(out)
83    }
84}
85
86///
87/// TESTS
88///
89
90#[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}