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::{
11            explain::ExplainPlan,
12            fingerprint::{finalize_sha256_digest, hash_parts, new_plan_fingerprint_hasher_v2},
13        },
14    },
15    traits::FieldValue,
16};
17
18///
19/// PlanFingerprint
20///
21/// Stable, deterministic fingerprint for logical plans.
22///
23
24#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
25pub struct PlanFingerprint([u8; 32]);
26
27impl PlanFingerprint {
28    #[must_use]
29    pub fn as_hex(&self) -> String {
30        encode_cursor(&self.0)
31    }
32}
33
34impl std::fmt::Display for PlanFingerprint {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(&self.as_hex())
37    }
38}
39
40impl<K> AccessPlannedQuery<K>
41where
42    K: FieldValue,
43{
44    /// Compute a stable fingerprint for this logical plan.
45    #[must_use]
46    #[cfg(test)]
47    pub(crate) fn fingerprint(&self) -> PlanFingerprint {
48        let explain = self.explain();
49        let projection = self.projection_spec_for_identity();
50        let mut hasher = new_plan_fingerprint_hasher_v2();
51        hash_parts::hash_explain_plan_profile_with_projection(
52            &mut hasher,
53            &explain,
54            hash_parts::ExplainHashProfile::FingerprintV2,
55            &projection,
56        );
57
58        PlanFingerprint(finalize_sha256_digest(hasher))
59    }
60}
61
62impl ExplainPlan {
63    /// Compute a stable fingerprint for this explain plan.
64    #[must_use]
65    pub fn fingerprint(&self) -> PlanFingerprint {
66        // Phase 1: hash canonical explain fields under the current fingerprint profile.
67        let mut hasher = new_plan_fingerprint_hasher_v2();
68        hash_parts::hash_explain_plan_profile(
69            &mut hasher,
70            self,
71            hash_parts::ExplainHashProfile::FingerprintV2,
72        );
73
74        // Phase 2: finalize into the fixed-width fingerprint payload.
75        PlanFingerprint(finalize_sha256_digest(hasher))
76    }
77}
78
79///
80/// TESTS
81///
82
83#[cfg(test)]
84mod tests {
85    use std::ops::Bound;
86
87    use crate::db::access::AccessPath;
88    use crate::db::predicate::{
89        CoercionId, CompareOp, ComparePredicate, MissingRowPolicy, Predicate,
90    };
91    use crate::db::query::explain::{ExplainGroupedStrategy, ExplainGrouping};
92    use crate::db::query::fingerprint::hash_parts;
93    use crate::db::query::intent::{KeyAccess, build_access_plan_from_keys};
94    use crate::db::query::plan::expr::{
95        Alias, BinaryOp, Expr, FieldId, ProjectionField, ProjectionSpec,
96    };
97    use crate::db::query::plan::{
98        AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
99        GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, LoadSpec, LogicalPlan, PageSpec,
100        QueryMode,
101    };
102    use crate::db::query::{builder::field::FieldRef, builder::sum};
103    use crate::model::index::IndexModel;
104    use crate::types::{Decimal, Ulid};
105    use crate::value::Value;
106
107    fn fingerprint_with_projection(
108        plan: &AccessPlannedQuery<Value>,
109        projection: &ProjectionSpec,
110    ) -> super::PlanFingerprint {
111        let explain = plan.explain();
112        let mut hasher = super::super::new_plan_fingerprint_hasher_v2();
113        hash_parts::hash_explain_plan_profile_with_projection(
114            &mut hasher,
115            &explain,
116            hash_parts::ExplainHashProfile::FingerprintV2,
117            projection,
118        );
119
120        super::PlanFingerprint(super::super::finalize_sha256_digest(hasher))
121    }
122
123    fn full_scan_query() -> AccessPlannedQuery<Value> {
124        AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
125    }
126
127    fn index_prefix_query(index: IndexModel, values: Vec<Value>) -> AccessPlannedQuery<Value> {
128        AccessPlannedQuery::new(
129            AccessPath::IndexPrefix { index, values },
130            MissingRowPolicy::Ignore,
131        )
132    }
133
134    fn index_range_query(
135        index: IndexModel,
136        prefix: Vec<Value>,
137        lower: Bound<Value>,
138        upper: Bound<Value>,
139    ) -> AccessPlannedQuery<Value> {
140        AccessPlannedQuery::new(
141            AccessPath::index_range(index, prefix, lower, upper),
142            MissingRowPolicy::Ignore,
143        )
144    }
145
146    fn grouped_explain_with_fixed_shape() -> crate::db::query::explain::ExplainPlan {
147        AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
148            .into_grouped(GroupSpec {
149                group_fields: vec![FieldSlot::from_parts_for_test(1, "rank")],
150                aggregates: vec![GroupAggregateSpec {
151                    kind: AggregateKind::Count,
152                    target_field: None,
153                    distinct: false,
154                }],
155                execution: GroupedExecutionConfig::with_hard_limits(64, 4096),
156            })
157            .explain()
158    }
159
160    #[test]
161    fn fingerprint_is_deterministic_for_equivalent_predicates() {
162        let id = Ulid::default();
163
164        let predicate_a = Predicate::And(vec![
165            FieldRef::new("id").eq(id),
166            FieldRef::new("other").eq(Value::Text("x".to_string())),
167        ]);
168        let predicate_b = Predicate::And(vec![
169            FieldRef::new("other").eq(Value::Text("x".to_string())),
170            FieldRef::new("id").eq(id),
171        ]);
172
173        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
174        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
175
176        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
177        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
178
179        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
180    }
181
182    #[test]
183    fn fingerprint_and_signature_are_stable_for_reordered_and_non_canonical_map_predicates() {
184        let map_a = Value::Map(vec![
185            (Value::Text("z".to_string()), Value::Int(9)),
186            (Value::Text("a".to_string()), Value::Int(1)),
187        ]);
188        let map_b = Value::Map(vec![
189            (Value::Text("a".to_string()), Value::Int(1)),
190            (Value::Text("z".to_string()), Value::Int(9)),
191        ]);
192
193        let predicate_a = Predicate::And(vec![
194            FieldRef::new("other").eq(Value::Text("x".to_string())),
195            Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_a)),
196        ]);
197        let predicate_b = Predicate::And(vec![
198            Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_b)),
199            FieldRef::new("other").eq(Value::Text("x".to_string())),
200        ]);
201
202        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
203        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
204
205        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
206        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
207
208        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
209        assert_eq!(
210            plan_a.continuation_signature("tests::Entity"),
211            plan_b.continuation_signature("tests::Entity")
212        );
213    }
214
215    #[test]
216    fn fingerprint_and_signature_treat_equivalent_decimal_predicate_literals_as_identical() {
217        let predicate_a = Predicate::Compare(ComparePredicate::eq(
218            "rank".to_string(),
219            Value::Decimal(Decimal::new(10, 1)),
220        ));
221        let predicate_b = Predicate::Compare(ComparePredicate::eq(
222            "rank".to_string(),
223            Value::Decimal(Decimal::new(100, 2)),
224        ));
225
226        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
227        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
228
229        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
230        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
231
232        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
233        assert_eq!(
234            plan_a.continuation_signature("tests::Entity"),
235            plan_b.continuation_signature("tests::Entity")
236        );
237    }
238
239    #[test]
240    fn fingerprint_and_signature_treat_equivalent_in_list_predicates_as_identical() {
241        let predicate_a = Predicate::Compare(ComparePredicate::in_(
242            "rank".to_string(),
243            vec![Value::Uint(3), Value::Uint(1), Value::Uint(2)],
244        ));
245        let predicate_b = Predicate::Compare(ComparePredicate::in_(
246            "rank".to_string(),
247            vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
248        ));
249
250        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
251        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
252
253        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
254        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
255
256        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
257        assert_eq!(
258            plan_a.continuation_signature("tests::Entity"),
259            plan_b.continuation_signature("tests::Entity")
260        );
261    }
262
263    #[test]
264    fn fingerprint_and_signature_treat_equivalent_in_list_duplicate_literals_as_identical() {
265        let predicate_a = Predicate::Compare(ComparePredicate::in_(
266            "rank".to_string(),
267            vec![
268                Value::Uint(3),
269                Value::Uint(1),
270                Value::Uint(3),
271                Value::Uint(2),
272            ],
273        ));
274        let predicate_b = Predicate::Compare(ComparePredicate::in_(
275            "rank".to_string(),
276            vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
277        ));
278
279        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
280        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
281
282        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
283        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
284
285        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
286        assert_eq!(
287            plan_a.continuation_signature("tests::Entity"),
288            plan_b.continuation_signature("tests::Entity")
289        );
290    }
291
292    #[test]
293    fn fingerprint_and_signature_treat_implicit_and_explicit_strict_coercion_as_identical() {
294        let predicate_a =
295            Predicate::Compare(ComparePredicate::eq("rank".to_string(), Value::Int(7)));
296        let predicate_b = Predicate::Compare(ComparePredicate::with_coercion(
297            "rank",
298            CompareOp::Eq,
299            Value::Int(7),
300            CoercionId::Strict,
301        ));
302
303        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
304        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
305
306        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
307        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
308
309        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
310        assert_eq!(
311            plan_a.continuation_signature("tests::Entity"),
312            plan_b.continuation_signature("tests::Entity")
313        );
314    }
315
316    #[test]
317    fn fingerprint_and_signature_distinguish_different_coercion_ids() {
318        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
319            "rank",
320            CompareOp::Eq,
321            Value::Int(7),
322            CoercionId::Strict,
323        ));
324        let predicate_numeric_widen = Predicate::Compare(ComparePredicate::with_coercion(
325            "rank",
326            CompareOp::Eq,
327            Value::Int(7),
328            CoercionId::NumericWiden,
329        ));
330
331        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
332        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
333
334        let mut numeric_widen_plan: AccessPlannedQuery<Value> = full_scan_query();
335        numeric_widen_plan.scalar_plan_mut().predicate = Some(predicate_numeric_widen);
336
337        assert_ne!(strict_plan.fingerprint(), numeric_widen_plan.fingerprint());
338        assert_ne!(
339            strict_plan.continuation_signature("tests::Entity"),
340            numeric_widen_plan.continuation_signature("tests::Entity")
341        );
342    }
343
344    #[test]
345    fn fingerprint_and_signature_treat_numeric_widen_equivalent_literal_subtypes_as_identical() {
346        let predicate_int = Predicate::Compare(ComparePredicate::with_coercion(
347            "rank",
348            CompareOp::Eq,
349            Value::Int(1),
350            CoercionId::NumericWiden,
351        ));
352        let predicate_decimal = Predicate::Compare(ComparePredicate::with_coercion(
353            "rank",
354            CompareOp::Eq,
355            Value::Decimal(Decimal::new(10, 1)),
356            CoercionId::NumericWiden,
357        ));
358
359        let mut int_plan: AccessPlannedQuery<Value> = full_scan_query();
360        int_plan.scalar_plan_mut().predicate = Some(predicate_int);
361
362        let mut decimal_plan: AccessPlannedQuery<Value> = full_scan_query();
363        decimal_plan.scalar_plan_mut().predicate = Some(predicate_decimal);
364
365        assert_eq!(int_plan.fingerprint(), decimal_plan.fingerprint());
366        assert_eq!(
367            int_plan.continuation_signature("tests::Entity"),
368            decimal_plan.continuation_signature("tests::Entity")
369        );
370    }
371
372    #[test]
373    fn fingerprint_and_signature_treat_text_casefold_case_only_literals_as_identical() {
374        let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
375            "name",
376            CompareOp::Eq,
377            Value::Text("ada".to_string()),
378            CoercionId::TextCasefold,
379        ));
380        let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
381            "name",
382            CompareOp::Eq,
383            Value::Text("ADA".to_string()),
384            CoercionId::TextCasefold,
385        ));
386
387        let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
388        lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
389
390        let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
391        upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
392
393        assert_eq!(lower_plan.fingerprint(), upper_plan.fingerprint());
394        assert_eq!(
395            lower_plan.continuation_signature("tests::Entity"),
396            upper_plan.continuation_signature("tests::Entity")
397        );
398    }
399
400    #[test]
401    fn fingerprint_and_signature_keep_strict_text_case_variants_distinct() {
402        let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
403            "name",
404            CompareOp::Eq,
405            Value::Text("ada".to_string()),
406            CoercionId::Strict,
407        ));
408        let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
409            "name",
410            CompareOp::Eq,
411            Value::Text("ADA".to_string()),
412            CoercionId::Strict,
413        ));
414
415        let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
416        lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
417
418        let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
419        upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
420
421        assert_ne!(lower_plan.fingerprint(), upper_plan.fingerprint());
422        assert_ne!(
423            lower_plan.continuation_signature("tests::Entity"),
424            upper_plan.continuation_signature("tests::Entity")
425        );
426    }
427
428    #[test]
429    fn fingerprint_and_signature_treat_text_casefold_in_list_case_variants_as_identical() {
430        let predicate_mixed = Predicate::Compare(ComparePredicate::with_coercion(
431            "name",
432            CompareOp::In,
433            Value::List(vec![
434                Value::Text("ADA".to_string()),
435                Value::Text("ada".to_string()),
436                Value::Text("Bob".to_string()),
437            ]),
438            CoercionId::TextCasefold,
439        ));
440        let predicate_canonical = Predicate::Compare(ComparePredicate::with_coercion(
441            "name",
442            CompareOp::In,
443            Value::List(vec![
444                Value::Text("ada".to_string()),
445                Value::Text("bob".to_string()),
446            ]),
447            CoercionId::TextCasefold,
448        ));
449
450        let mut mixed_plan: AccessPlannedQuery<Value> = full_scan_query();
451        mixed_plan.scalar_plan_mut().predicate = Some(predicate_mixed);
452
453        let mut canonical_plan: AccessPlannedQuery<Value> = full_scan_query();
454        canonical_plan.scalar_plan_mut().predicate = Some(predicate_canonical);
455
456        assert_eq!(mixed_plan.fingerprint(), canonical_plan.fingerprint());
457        assert_eq!(
458            mixed_plan.continuation_signature("tests::Entity"),
459            canonical_plan.continuation_signature("tests::Entity")
460        );
461    }
462
463    #[test]
464    fn fingerprint_and_signature_distinguish_strict_from_text_casefold_coercion() {
465        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
466            "name",
467            CompareOp::Eq,
468            Value::Text("ada".to_string()),
469            CoercionId::Strict,
470        ));
471        let predicate_casefold = Predicate::Compare(ComparePredicate::with_coercion(
472            "name",
473            CompareOp::Eq,
474            Value::Text("ada".to_string()),
475            CoercionId::TextCasefold,
476        ));
477
478        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
479        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
480
481        let mut casefold_plan: AccessPlannedQuery<Value> = full_scan_query();
482        casefold_plan.scalar_plan_mut().predicate = Some(predicate_casefold);
483
484        assert_ne!(strict_plan.fingerprint(), casefold_plan.fingerprint());
485        assert_ne!(
486            strict_plan.continuation_signature("tests::Entity"),
487            casefold_plan.continuation_signature("tests::Entity")
488        );
489    }
490
491    #[test]
492    fn fingerprint_and_signature_distinguish_strict_from_collection_element_coercion() {
493        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
494            "rank",
495            CompareOp::Eq,
496            Value::Int(7),
497            CoercionId::Strict,
498        ));
499        let predicate_collection_element = Predicate::Compare(ComparePredicate::with_coercion(
500            "rank",
501            CompareOp::Eq,
502            Value::Int(7),
503            CoercionId::CollectionElement,
504        ));
505
506        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
507        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
508
509        let mut collection_plan: AccessPlannedQuery<Value> = full_scan_query();
510        collection_plan.scalar_plan_mut().predicate = Some(predicate_collection_element);
511
512        assert_ne!(strict_plan.fingerprint(), collection_plan.fingerprint());
513        assert_ne!(
514            strict_plan.continuation_signature("tests::Entity"),
515            collection_plan.continuation_signature("tests::Entity")
516        );
517    }
518
519    #[test]
520    fn fingerprint_is_deterministic_for_by_keys() {
521        let a = Ulid::from_u128(1);
522        let b = Ulid::from_u128(2);
523
524        let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
525        let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
526
527        let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
528            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
529                mode: QueryMode::Load(LoadSpec::new()),
530                predicate: None,
531                order: None,
532                distinct: false,
533                delete_limit: None,
534                page: None,
535                consistency: MissingRowPolicy::Ignore,
536            }),
537            access: access_a,
538        };
539        let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
540            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
541                mode: QueryMode::Load(LoadSpec::new()),
542                predicate: None,
543                order: None,
544                distinct: false,
545                delete_limit: None,
546                page: None,
547                consistency: MissingRowPolicy::Ignore,
548            }),
549            access: access_b,
550        };
551
552        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
553    }
554
555    #[test]
556    fn fingerprint_changes_with_index_choice() {
557        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
558        const INDEX_A: IndexModel = IndexModel::new(
559            "fingerprint::idx_a",
560            "fingerprint::store",
561            &INDEX_FIELDS,
562            false,
563        );
564        const INDEX_B: IndexModel = IndexModel::new(
565            "fingerprint::idx_b",
566            "fingerprint::store",
567            &INDEX_FIELDS,
568            false,
569        );
570
571        let plan_a: AccessPlannedQuery<Value> =
572            index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
573        let plan_b: AccessPlannedQuery<Value> =
574            index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
575
576        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
577    }
578
579    #[test]
580    fn fingerprint_changes_with_pagination() {
581        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
582        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
583        plan_a.scalar_plan_mut().page = Some(PageSpec {
584            limit: Some(10),
585            offset: 0,
586        });
587        plan_b.scalar_plan_mut().page = Some(PageSpec {
588            limit: Some(10),
589            offset: 1,
590        });
591
592        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
593    }
594
595    #[test]
596    fn fingerprint_changes_with_delete_limit() {
597        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
598        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
599        plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
600        plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
601        plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
602        plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
603
604        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
605    }
606
607    #[test]
608    fn fingerprint_changes_with_distinct_flag() {
609        let plan_a: AccessPlannedQuery<Value> = full_scan_query();
610        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
611        plan_b.scalar_plan_mut().distinct = true;
612
613        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
614    }
615
616    #[test]
617    fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
618        let plan: AccessPlannedQuery<Value> = full_scan_query();
619        let numeric_projection =
620            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
621                expr: Expr::Binary {
622                    op: crate::db::query::plan::expr::BinaryOp::Add,
623                    left: Box::new(Expr::Field(FieldId::new("rank"))),
624                    right: Box::new(Expr::Literal(Value::Int(1))),
625                },
626                alias: None,
627            }]);
628        let alias_only_numeric_projection =
629            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
630                expr: Expr::Alias {
631                    expr: Box::new(Expr::Binary {
632                        op: crate::db::query::plan::expr::BinaryOp::Add,
633                        left: Box::new(Expr::Field(FieldId::new("rank"))),
634                        right: Box::new(Expr::Literal(Value::Int(1))),
635                    }),
636                    name: Alias::new("rank_plus_one_expr"),
637                },
638                alias: Some(Alias::new("rank_plus_one")),
639            }]);
640
641        let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
642        let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
643
644        assert_eq!(
645            semantic_fingerprint, alias_fingerprint,
646            "numeric projection alias wrappers must not affect fingerprint identity",
647        );
648    }
649
650    #[test]
651    fn fingerprint_numeric_projection_semantic_change_invalidates() {
652        let plan: AccessPlannedQuery<Value> = full_scan_query();
653        let projection_add_one =
654            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
655                expr: Expr::Binary {
656                    op: crate::db::query::plan::expr::BinaryOp::Add,
657                    left: Box::new(Expr::Field(FieldId::new("rank"))),
658                    right: Box::new(Expr::Literal(Value::Int(1))),
659                },
660                alias: None,
661            }]);
662        let projection_mul_one =
663            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
664                expr: Expr::Binary {
665                    op: crate::db::query::plan::expr::BinaryOp::Mul,
666                    left: Box::new(Expr::Field(FieldId::new("rank"))),
667                    right: Box::new(Expr::Literal(Value::Int(1))),
668                },
669                alias: None,
670            }]);
671
672        let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
673        let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
674
675        assert_ne!(
676            add_fingerprint, mul_fingerprint,
677            "numeric projection semantic changes must invalidate fingerprint identity",
678        );
679    }
680
681    #[test]
682    fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
683        let plan: AccessPlannedQuery<Value> = full_scan_query();
684        let decimal_one_scale_1 =
685            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
686                expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
687                alias: None,
688            }]);
689        let decimal_one_scale_2 =
690            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
691                expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
692                alias: None,
693            }]);
694
695        assert_eq!(
696            fingerprint_with_projection(&plan, &decimal_one_scale_1),
697            fingerprint_with_projection(&plan, &decimal_one_scale_2),
698            "decimal scale-only literal changes must not fragment fingerprint identity",
699        );
700    }
701
702    #[test]
703    fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
704        let plan: AccessPlannedQuery<Value> = full_scan_query();
705        let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
706            expr: Expr::Literal(Value::Int(1)),
707            alias: None,
708        }]);
709        let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
710            expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
711            alias: None,
712        }]);
713
714        assert_ne!(
715            fingerprint_with_projection(&plan, &int_literal),
716            fingerprint_with_projection(&plan, &decimal_literal),
717            "top-level literal subtype remains observable and identity-significant",
718        );
719    }
720
721    #[test]
722    fn fingerprint_numeric_promotion_paths_do_not_fragment() {
723        let plan: AccessPlannedQuery<Value> = full_scan_query();
724        let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
725            expr: Expr::Binary {
726                op: BinaryOp::Add,
727                left: Box::new(Expr::Literal(Value::Int(1))),
728                right: Box::new(Expr::Literal(Value::Int(2))),
729            },
730            alias: None,
731        }]);
732        let int_plus_decimal =
733            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
734                expr: Expr::Binary {
735                    op: BinaryOp::Add,
736                    left: Box::new(Expr::Literal(Value::Int(1))),
737                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
738                },
739                alias: None,
740            }]);
741        let decimal_plus_int =
742            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
743                expr: Expr::Binary {
744                    op: BinaryOp::Add,
745                    left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
746                    right: Box::new(Expr::Literal(Value::Int(2))),
747                },
748                alias: None,
749            }]);
750
751        let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
752        let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
753        let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
754
755        assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
756        assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
757    }
758
759    #[test]
760    fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
761        let plan: AccessPlannedQuery<Value> = full_scan_query();
762        let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
763            expr: Expr::Binary {
764                op: BinaryOp::Add,
765                left: Box::new(Expr::Field(FieldId::new("rank"))),
766                right: Box::new(Expr::Field(FieldId::new("score"))),
767            },
768            alias: None,
769        }]);
770        let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
771            expr: Expr::Binary {
772                op: BinaryOp::Add,
773                left: Box::new(Expr::Field(FieldId::new("score"))),
774                right: Box::new(Expr::Field(FieldId::new("rank"))),
775            },
776            alias: None,
777        }]);
778
779        assert_ne!(
780            fingerprint_with_projection(&plan, &rank_plus_score),
781            fingerprint_with_projection(&plan, &score_plus_rank),
782            "fingerprint preserves AST operand order for commutative operators in v2",
783        );
784    }
785
786    #[test]
787    fn fingerprint_aggregate_numeric_target_field_remains_significant() {
788        let plan: AccessPlannedQuery<Value> = full_scan_query();
789        let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
790            expr: Expr::Aggregate(sum("rank")),
791            alias: None,
792        }]);
793        let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
794            expr: Expr::Aggregate(sum("score")),
795            alias: None,
796        }]);
797
798        assert_ne!(
799            fingerprint_with_projection(&plan, &sum_rank),
800            fingerprint_with_projection(&plan, &sum_score),
801            "aggregate target field changes must invalidate fingerprint identity",
802        );
803    }
804
805    #[test]
806    fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
807        let plan: AccessPlannedQuery<Value> = full_scan_query();
808        let sum_distinct_plus_int_zero =
809            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
810                expr: Expr::Binary {
811                    op: BinaryOp::Add,
812                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
813                    right: Box::new(Expr::Literal(Value::Int(0))),
814                },
815                alias: None,
816            }]);
817        let sum_distinct_plus_decimal_zero =
818            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
819                expr: Expr::Binary {
820                    op: BinaryOp::Add,
821                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
822                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
823                },
824                alias: None,
825            }]);
826
827        assert_eq!(
828            fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
829            fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
830            "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
831        );
832    }
833
834    #[test]
835    fn fingerprint_is_stable_for_full_scan() {
836        let plan: AccessPlannedQuery<Value> = full_scan_query();
837        let fingerprint_a = plan.fingerprint();
838        let fingerprint_b = plan.fingerprint();
839        assert_eq!(fingerprint_a, fingerprint_b);
840    }
841
842    #[test]
843    fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
844        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
845        const INDEX: IndexModel = IndexModel::new(
846            "fingerprint::group_rank",
847            "fingerprint::store",
848            &INDEX_FIELDS,
849            false,
850        );
851
852        let plan_a: AccessPlannedQuery<Value> = index_range_query(
853            INDEX,
854            vec![Value::Uint(7)],
855            Bound::Included(Value::Uint(100)),
856            Bound::Excluded(Value::Uint(200)),
857        );
858        let plan_b: AccessPlannedQuery<Value> = index_range_query(
859            INDEX,
860            vec![Value::Uint(7)],
861            Bound::Included(Value::Uint(100)),
862            Bound::Excluded(Value::Uint(200)),
863        );
864
865        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
866    }
867
868    #[test]
869    fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
870        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
871        const INDEX: IndexModel = IndexModel::new(
872            "fingerprint::group_rank",
873            "fingerprint::store",
874            &INDEX_FIELDS,
875            false,
876        );
877
878        let plan_included: AccessPlannedQuery<Value> = index_range_query(
879            INDEX,
880            vec![Value::Uint(7)],
881            Bound::Included(Value::Uint(100)),
882            Bound::Excluded(Value::Uint(200)),
883        );
884        let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
885            INDEX,
886            vec![Value::Uint(7)],
887            Bound::Excluded(Value::Uint(100)),
888            Bound::Excluded(Value::Uint(200)),
889        );
890
891        assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
892    }
893
894    #[test]
895    fn fingerprint_changes_when_index_range_bound_value_changes() {
896        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
897        const INDEX: IndexModel = IndexModel::new(
898            "fingerprint::group_rank",
899            "fingerprint::store",
900            &INDEX_FIELDS,
901            false,
902        );
903
904        let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
905            INDEX,
906            vec![Value::Uint(7)],
907            Bound::Included(Value::Uint(100)),
908            Bound::Excluded(Value::Uint(200)),
909        );
910        let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
911            INDEX,
912            vec![Value::Uint(7)],
913            Bound::Included(Value::Uint(101)),
914            Bound::Excluded(Value::Uint(200)),
915        );
916
917        assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
918    }
919
920    #[test]
921    fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
922        let mut hash_strategy = grouped_explain_with_fixed_shape();
923        let mut ordered_strategy = hash_strategy.clone();
924
925        let ExplainGrouping::Grouped {
926            strategy: hash_value,
927            ..
928        } = &mut hash_strategy.grouping
929        else {
930            panic!("grouped explain fixture must produce grouped explain shape");
931        };
932        *hash_value = ExplainGroupedStrategy::HashGroup;
933        let ExplainGrouping::Grouped {
934            strategy: ordered_value,
935            ..
936        } = &mut ordered_strategy.grouping
937        else {
938            panic!("grouped explain fixture must produce grouped explain shape");
939        };
940        *ordered_value = ExplainGroupedStrategy::OrderedGroup;
941
942        assert_eq!(
943            hash_strategy.fingerprint(),
944            ordered_strategy.fingerprint(),
945            "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
946        );
947    }
948}