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