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_and_signature_treat_equivalent_decimal_predicate_literals_as_identical() {
227        let predicate_a = Predicate::Compare(ComparePredicate::eq(
228            "rank".to_string(),
229            Value::Decimal(Decimal::new(10, 1)),
230        ));
231        let predicate_b = Predicate::Compare(ComparePredicate::eq(
232            "rank".to_string(),
233            Value::Decimal(Decimal::new(100, 2)),
234        ));
235
236        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
237        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
238
239        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
240        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
241
242        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
243        assert_eq!(
244            plan_a.continuation_signature("tests::Entity"),
245            plan_b.continuation_signature("tests::Entity")
246        );
247    }
248
249    #[test]
250    fn fingerprint_is_deterministic_for_by_keys() {
251        let a = Ulid::from_u128(1);
252        let b = Ulid::from_u128(2);
253
254        let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
255        let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
256
257        let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
258            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
259                mode: QueryMode::Load(LoadSpec::new()),
260                predicate: None,
261                order: None,
262                distinct: false,
263                delete_limit: None,
264                page: None,
265                consistency: MissingRowPolicy::Ignore,
266            }),
267            access: access_a,
268        };
269        let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
270            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
271                mode: QueryMode::Load(LoadSpec::new()),
272                predicate: None,
273                order: None,
274                distinct: false,
275                delete_limit: None,
276                page: None,
277                consistency: MissingRowPolicy::Ignore,
278            }),
279            access: access_b,
280        };
281
282        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
283    }
284
285    #[test]
286    fn fingerprint_changes_with_index_choice() {
287        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
288        const INDEX_A: IndexModel = IndexModel::new(
289            "fingerprint::idx_a",
290            "fingerprint::store",
291            &INDEX_FIELDS,
292            false,
293        );
294        const INDEX_B: IndexModel = IndexModel::new(
295            "fingerprint::idx_b",
296            "fingerprint::store",
297            &INDEX_FIELDS,
298            false,
299        );
300
301        let plan_a: AccessPlannedQuery<Value> =
302            index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
303        let plan_b: AccessPlannedQuery<Value> =
304            index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
305
306        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
307    }
308
309    #[test]
310    fn fingerprint_changes_with_pagination() {
311        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
312        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
313        plan_a.scalar_plan_mut().page = Some(PageSpec {
314            limit: Some(10),
315            offset: 0,
316        });
317        plan_b.scalar_plan_mut().page = Some(PageSpec {
318            limit: Some(10),
319            offset: 1,
320        });
321
322        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
323    }
324
325    #[test]
326    fn fingerprint_changes_with_delete_limit() {
327        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
328        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
329        plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
330        plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
331        plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
332        plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
333
334        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
335    }
336
337    #[test]
338    fn fingerprint_changes_with_distinct_flag() {
339        let plan_a: AccessPlannedQuery<Value> = full_scan_query();
340        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
341        plan_b.scalar_plan_mut().distinct = true;
342
343        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
344    }
345
346    #[test]
347    fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
348        let plan: AccessPlannedQuery<Value> = full_scan_query();
349        let numeric_projection =
350            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
351                expr: Expr::Binary {
352                    op: crate::db::query::plan::expr::BinaryOp::Add,
353                    left: Box::new(Expr::Field(FieldId::new("rank"))),
354                    right: Box::new(Expr::Literal(Value::Int(1))),
355                },
356                alias: None,
357            }]);
358        let alias_only_numeric_projection =
359            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
360                expr: Expr::Alias {
361                    expr: Box::new(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                    name: Alias::new("rank_plus_one_expr"),
367                },
368                alias: Some(Alias::new("rank_plus_one")),
369            }]);
370
371        let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
372        let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
373
374        assert_eq!(
375            semantic_fingerprint, alias_fingerprint,
376            "numeric projection alias wrappers must not affect fingerprint identity",
377        );
378    }
379
380    #[test]
381    fn fingerprint_numeric_projection_semantic_change_invalidates() {
382        let plan: AccessPlannedQuery<Value> = full_scan_query();
383        let projection_add_one =
384            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
385                expr: Expr::Binary {
386                    op: crate::db::query::plan::expr::BinaryOp::Add,
387                    left: Box::new(Expr::Field(FieldId::new("rank"))),
388                    right: Box::new(Expr::Literal(Value::Int(1))),
389                },
390                alias: None,
391            }]);
392        let projection_mul_one =
393            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
394                expr: Expr::Binary {
395                    op: crate::db::query::plan::expr::BinaryOp::Mul,
396                    left: Box::new(Expr::Field(FieldId::new("rank"))),
397                    right: Box::new(Expr::Literal(Value::Int(1))),
398                },
399                alias: None,
400            }]);
401
402        let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
403        let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
404
405        assert_ne!(
406            add_fingerprint, mul_fingerprint,
407            "numeric projection semantic changes must invalidate fingerprint identity",
408        );
409    }
410
411    #[test]
412    fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
413        let plan: AccessPlannedQuery<Value> = full_scan_query();
414        let decimal_one_scale_1 =
415            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
416                expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
417                alias: None,
418            }]);
419        let decimal_one_scale_2 =
420            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
421                expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
422                alias: None,
423            }]);
424
425        assert_eq!(
426            fingerprint_with_projection(&plan, &decimal_one_scale_1),
427            fingerprint_with_projection(&plan, &decimal_one_scale_2),
428            "decimal scale-only literal changes must not fragment fingerprint identity",
429        );
430    }
431
432    #[test]
433    fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
434        let plan: AccessPlannedQuery<Value> = full_scan_query();
435        let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
436            expr: Expr::Literal(Value::Int(1)),
437            alias: None,
438        }]);
439        let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
440            expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
441            alias: None,
442        }]);
443
444        assert_ne!(
445            fingerprint_with_projection(&plan, &int_literal),
446            fingerprint_with_projection(&plan, &decimal_literal),
447            "top-level literal subtype remains observable and identity-significant",
448        );
449    }
450
451    #[test]
452    fn fingerprint_numeric_promotion_paths_do_not_fragment() {
453        let plan: AccessPlannedQuery<Value> = full_scan_query();
454        let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
455            expr: Expr::Binary {
456                op: BinaryOp::Add,
457                left: Box::new(Expr::Literal(Value::Int(1))),
458                right: Box::new(Expr::Literal(Value::Int(2))),
459            },
460            alias: None,
461        }]);
462        let int_plus_decimal =
463            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
464                expr: Expr::Binary {
465                    op: BinaryOp::Add,
466                    left: Box::new(Expr::Literal(Value::Int(1))),
467                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
468                },
469                alias: None,
470            }]);
471        let decimal_plus_int =
472            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
473                expr: Expr::Binary {
474                    op: BinaryOp::Add,
475                    left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
476                    right: Box::new(Expr::Literal(Value::Int(2))),
477                },
478                alias: None,
479            }]);
480
481        let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
482        let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
483        let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
484
485        assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
486        assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
487    }
488
489    #[test]
490    fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
491        let plan: AccessPlannedQuery<Value> = full_scan_query();
492        let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
493            expr: Expr::Binary {
494                op: BinaryOp::Add,
495                left: Box::new(Expr::Field(FieldId::new("rank"))),
496                right: Box::new(Expr::Field(FieldId::new("score"))),
497            },
498            alias: None,
499        }]);
500        let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
501            expr: Expr::Binary {
502                op: BinaryOp::Add,
503                left: Box::new(Expr::Field(FieldId::new("score"))),
504                right: Box::new(Expr::Field(FieldId::new("rank"))),
505            },
506            alias: None,
507        }]);
508
509        assert_ne!(
510            fingerprint_with_projection(&plan, &rank_plus_score),
511            fingerprint_with_projection(&plan, &score_plus_rank),
512            "fingerprint preserves AST operand order for commutative operators in v2",
513        );
514    }
515
516    #[test]
517    fn fingerprint_aggregate_numeric_target_field_remains_significant() {
518        let plan: AccessPlannedQuery<Value> = full_scan_query();
519        let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
520            expr: Expr::Aggregate(sum("rank")),
521            alias: None,
522        }]);
523        let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
524            expr: Expr::Aggregate(sum("score")),
525            alias: None,
526        }]);
527
528        assert_ne!(
529            fingerprint_with_projection(&plan, &sum_rank),
530            fingerprint_with_projection(&plan, &sum_score),
531            "aggregate target field changes must invalidate fingerprint identity",
532        );
533    }
534
535    #[test]
536    fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
537        let plan: AccessPlannedQuery<Value> = full_scan_query();
538        let sum_distinct_plus_int_zero =
539            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
540                expr: Expr::Binary {
541                    op: BinaryOp::Add,
542                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
543                    right: Box::new(Expr::Literal(Value::Int(0))),
544                },
545                alias: None,
546            }]);
547        let sum_distinct_plus_decimal_zero =
548            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
549                expr: Expr::Binary {
550                    op: BinaryOp::Add,
551                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
552                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
553                },
554                alias: None,
555            }]);
556
557        assert_eq!(
558            fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
559            fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
560            "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
561        );
562    }
563
564    #[test]
565    fn fingerprint_is_stable_for_full_scan() {
566        let plan: AccessPlannedQuery<Value> = full_scan_query();
567        let fingerprint_a = plan.fingerprint();
568        let fingerprint_b = plan.fingerprint();
569        assert_eq!(fingerprint_a, fingerprint_b);
570    }
571
572    #[test]
573    fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
574        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
575        const INDEX: IndexModel = IndexModel::new(
576            "fingerprint::group_rank",
577            "fingerprint::store",
578            &INDEX_FIELDS,
579            false,
580        );
581
582        let plan_a: AccessPlannedQuery<Value> = index_range_query(
583            INDEX,
584            vec![Value::Uint(7)],
585            Bound::Included(Value::Uint(100)),
586            Bound::Excluded(Value::Uint(200)),
587        );
588        let plan_b: AccessPlannedQuery<Value> = index_range_query(
589            INDEX,
590            vec![Value::Uint(7)],
591            Bound::Included(Value::Uint(100)),
592            Bound::Excluded(Value::Uint(200)),
593        );
594
595        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
596    }
597
598    #[test]
599    fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
600        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
601        const INDEX: IndexModel = IndexModel::new(
602            "fingerprint::group_rank",
603            "fingerprint::store",
604            &INDEX_FIELDS,
605            false,
606        );
607
608        let plan_included: AccessPlannedQuery<Value> = index_range_query(
609            INDEX,
610            vec![Value::Uint(7)],
611            Bound::Included(Value::Uint(100)),
612            Bound::Excluded(Value::Uint(200)),
613        );
614        let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
615            INDEX,
616            vec![Value::Uint(7)],
617            Bound::Excluded(Value::Uint(100)),
618            Bound::Excluded(Value::Uint(200)),
619        );
620
621        assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
622    }
623
624    #[test]
625    fn fingerprint_changes_when_index_range_bound_value_changes() {
626        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
627        const INDEX: IndexModel = IndexModel::new(
628            "fingerprint::group_rank",
629            "fingerprint::store",
630            &INDEX_FIELDS,
631            false,
632        );
633
634        let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
635            INDEX,
636            vec![Value::Uint(7)],
637            Bound::Included(Value::Uint(100)),
638            Bound::Excluded(Value::Uint(200)),
639        );
640        let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
641            INDEX,
642            vec![Value::Uint(7)],
643            Bound::Included(Value::Uint(101)),
644            Bound::Excluded(Value::Uint(200)),
645        );
646
647        assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
648    }
649
650    #[test]
651    fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
652        let mut hash_strategy = grouped_explain_with_fixed_shape();
653        let mut ordered_strategy = hash_strategy.clone();
654
655        let ExplainGrouping::Grouped {
656            strategy: hash_value,
657            ..
658        } = &mut hash_strategy.grouping
659        else {
660            panic!("grouped explain fixture must produce grouped explain shape");
661        };
662        *hash_value = ExplainGroupedStrategy::HashGroup;
663        let ExplainGrouping::Grouped {
664            strategy: ordered_value,
665            ..
666        } = &mut ordered_strategy.grouping
667        else {
668            panic!("grouped explain fixture must produce grouped explain shape");
669        };
670        *ordered_value = ExplainGroupedStrategy::OrderedGroup;
671
672        assert_eq!(
673            hash_strategy.fingerprint(),
674            ordered_strategy.fingerprint(),
675            "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
676        );
677    }
678}