Skip to main content

lemma/planning/
fingerprint.rs

1//! Semantic plan fingerprint for content-addressable hashing.
2//!
3//! Projects ExecutionPlan onto a representation that contains only what the plan actually does.
4//! Uses dedicated fingerprint types (no LemmaType, LiteralValue, Arc<LemmaSpec>) so the hash
5//! does not depend on external types or other specs. Excludes meta and source locations.
6//! Schema is explicit and stable: adding Rust fields does not change hashes for unused content.
7//!
8//! **Format versioning:** `fingerprint_hash` hashes `LMFP` + big-endian `FINGERPRINT_FORMAT_VERSION`
9//! (u32) + postcard(`PlanFingerprint`). Bump the version when the encoded semantics change in a
10//! way that must not share hashes with prior formats.
11
12use crate::parsing::ast::{
13    CalendarUnit, DateCalendarKind, DateRelativeKind, DateTimeValue, DurationUnit, TimeValue,
14};
15use crate::planning::execution_plan::{Branch, ExecutableRule, ExecutionPlan, SpecId};
16use crate::planning::semantics::{
17    ArithmeticComputation, ComparisonComputation, Expression, ExpressionKind, FactData, FactPath,
18    LemmaType, LiteralValue, MathematicalComputation, NegationType, RatioUnit, RatioUnits,
19    RulePath, ScaleUnit, ScaleUnits, SemanticConversionTarget, TypeDefiningSpec, TypeExtends,
20    TypeSpecification, ValueKind, VetoExpression,
21};
22use rust_decimal::Decimal;
23use serde::Serialize;
24use sha2::{Digest, Sha256};
25use std::collections::BTreeMap;
26
27/// Bumped when the byte layout hashed by [`fingerprint_hash`] changes incompatibly (prefix + postcard).
28pub const FINGERPRINT_FORMAT_VERSION: u32 = 1;
29
30const FINGERPRINT_MAGIC: &[u8; 4] = b"LMFP";
31
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TypeDefiningSpecFingerprint {
35    Local,
36    Import {
37        /// Spec identifier: name or name~hash when pinned (e.g. `dep` or `dep~a1b2c3d4`).
38        spec_id: String,
39        effective_from: Option<DateTimeValue>,
40    },
41}
42
43#[derive(Debug, Clone, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TypeExtendsFingerprint {
46    Primitive,
47    Custom {
48        parent: String,
49        family: String,
50        defining_spec: TypeDefiningSpecFingerprint,
51    },
52}
53
54/// Mirrors [`TypeSpecification`] with order-independent vecs (sorted) for fingerprinting.
55#[derive(Debug, Clone, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum TypeSpecificationFingerprint {
58    Boolean {
59        help: String,
60        default: Option<bool>,
61    },
62    Scale {
63        minimum: Option<Decimal>,
64        maximum: Option<Decimal>,
65        decimals: Option<u8>,
66        precision: Option<Decimal>,
67        units: ScaleUnits,
68        help: String,
69        default: Option<(Decimal, String)>,
70    },
71    Number {
72        minimum: Option<Decimal>,
73        maximum: Option<Decimal>,
74        decimals: Option<u8>,
75        precision: Option<Decimal>,
76        help: String,
77        default: Option<Decimal>,
78    },
79    Ratio {
80        minimum: Option<Decimal>,
81        maximum: Option<Decimal>,
82        decimals: Option<u8>,
83        units: RatioUnits,
84        help: String,
85        default: Option<Decimal>,
86    },
87    Text {
88        minimum: Option<usize>,
89        maximum: Option<usize>,
90        length: Option<usize>,
91        options: Vec<String>,
92        help: String,
93        default: Option<String>,
94    },
95    Date {
96        minimum: Option<DateTimeValue>,
97        maximum: Option<DateTimeValue>,
98        help: String,
99        default: Option<DateTimeValue>,
100    },
101    Time {
102        minimum: Option<TimeValue>,
103        maximum: Option<TimeValue>,
104        help: String,
105        default: Option<TimeValue>,
106    },
107    Duration {
108        help: String,
109        default: Option<(Decimal, DurationUnit)>,
110    },
111    Veto {
112        message: Option<String>,
113    },
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct LemmaTypeFingerprint {
118    pub name: Option<String>,
119    pub specifications: TypeSpecificationFingerprint,
120    pub extends: TypeExtendsFingerprint,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub struct LiteralValueFingerprint {
125    pub value: ValueKind,
126    pub lemma_type: LemmaTypeFingerprint,
127}
128
129/// Semantic fingerprint of an execution plan. Contains only content that affects evaluation.
130#[derive(Debug, Clone, Serialize)]
131pub struct PlanFingerprint {
132    pub spec_name: String,
133    pub valid_from: Option<DateTimeValue>,
134    pub facts: BTreeMap<FactPath, FactFingerprint>,
135    pub rules: BTreeMap<RulePath, RuleFingerprint>,
136    pub named_types: BTreeMap<String, LemmaTypeFingerprint>,
137}
138
139#[derive(Debug, Clone, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum FactFingerprint {
142    Value {
143        value: LiteralValueFingerprint,
144        is_default: bool,
145    },
146    TypeDeclaration {
147        resolved_type: LemmaTypeFingerprint,
148    },
149    SpecRef {
150        /// Spec identifier: name or name~hash when pinned (e.g. `dep` or `dep~a1b2c3d4`).
151        spec_id: String,
152        effective_from: Option<DateTimeValue>,
153    },
154}
155
156#[derive(Debug, Clone, Serialize)]
157pub struct RuleFingerprint {
158    pub path: RulePath,
159    pub branches: Vec<BranchFingerprint>,
160    pub needs_facts: Vec<FactPath>,
161    pub rule_type: LemmaTypeFingerprint,
162}
163
164#[derive(Debug, Clone, Serialize)]
165pub struct BranchFingerprint {
166    pub condition: Option<ExpressionFingerprint>,
167    pub result: ExpressionFingerprint,
168}
169
170#[derive(Debug, Clone, Serialize)]
171pub struct ExpressionFingerprint {
172    pub kind: ExpressionKindFingerprint,
173}
174
175#[derive(Debug, Clone, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub enum ExpressionKindFingerprint {
178    Literal(Box<LiteralValueFingerprint>),
179    FactPath(FactPath),
180    RulePath(RulePath),
181    LogicalAnd(Box<ExpressionFingerprint>, Box<ExpressionFingerprint>),
182    Arithmetic(
183        Box<ExpressionFingerprint>,
184        ArithmeticComputation,
185        Box<ExpressionFingerprint>,
186    ),
187    Comparison(
188        Box<ExpressionFingerprint>,
189        ComparisonComputation,
190        Box<ExpressionFingerprint>,
191    ),
192    UnitConversion(Box<ExpressionFingerprint>, SemanticConversionTarget),
193    LogicalNegation(Box<ExpressionFingerprint>, NegationType),
194    MathematicalComputation(MathematicalComputation, Box<ExpressionFingerprint>),
195    Veto(VetoExpression),
196    Now,
197    DateRelative(
198        DateRelativeKind,
199        Box<ExpressionFingerprint>,
200        Option<Box<ExpressionFingerprint>>,
201    ),
202    DateCalendar(DateCalendarKind, CalendarUnit, Box<ExpressionFingerprint>),
203}
204
205fn type_spec_fingerprint(ts: &TypeSpecification) -> TypeSpecificationFingerprint {
206    match ts {
207        TypeSpecification::Boolean { help, default } => TypeSpecificationFingerprint::Boolean {
208            help: help.clone(),
209            default: *default,
210        },
211        TypeSpecification::Scale {
212            minimum,
213            maximum,
214            decimals,
215            precision,
216            units,
217            help,
218            default,
219        } => {
220            let mut sorted: Vec<ScaleUnit> = units.iter().cloned().collect();
221            sorted.sort_by(|a, b| a.name.cmp(&b.name));
222            TypeSpecificationFingerprint::Scale {
223                minimum: *minimum,
224                maximum: *maximum,
225                decimals: *decimals,
226                precision: *precision,
227                units: ScaleUnits::from(sorted),
228                help: help.clone(),
229                default: default.clone(),
230            }
231        }
232        TypeSpecification::Number {
233            minimum,
234            maximum,
235            decimals,
236            precision,
237            help,
238            default,
239        } => TypeSpecificationFingerprint::Number {
240            minimum: *minimum,
241            maximum: *maximum,
242            decimals: *decimals,
243            precision: *precision,
244            help: help.clone(),
245            default: *default,
246        },
247        TypeSpecification::Ratio {
248            minimum,
249            maximum,
250            decimals,
251            units,
252            help,
253            default,
254        } => {
255            let mut sorted: Vec<RatioUnit> = units.iter().cloned().collect();
256            sorted.sort_by(|a, b| a.name.cmp(&b.name));
257            TypeSpecificationFingerprint::Ratio {
258                minimum: *minimum,
259                maximum: *maximum,
260                decimals: *decimals,
261                units: RatioUnits::from(sorted),
262                help: help.clone(),
263                default: *default,
264            }
265        }
266        TypeSpecification::Text {
267            minimum,
268            maximum,
269            length,
270            options,
271            help,
272            default,
273        } => {
274            let mut sorted_opts = options.clone();
275            sorted_opts.sort();
276            TypeSpecificationFingerprint::Text {
277                minimum: *minimum,
278                maximum: *maximum,
279                length: *length,
280                options: sorted_opts,
281                help: help.clone(),
282                default: default.clone(),
283            }
284        }
285        TypeSpecification::Date {
286            minimum,
287            maximum,
288            help,
289            default,
290        } => TypeSpecificationFingerprint::Date {
291            minimum: minimum.clone(),
292            maximum: maximum.clone(),
293            help: help.clone(),
294            default: default.clone(),
295        },
296        TypeSpecification::Time {
297            minimum,
298            maximum,
299            help,
300            default,
301        } => TypeSpecificationFingerprint::Time {
302            minimum: minimum.clone(),
303            maximum: maximum.clone(),
304            help: help.clone(),
305            default: default.clone(),
306        },
307        TypeSpecification::Duration { help, default } => TypeSpecificationFingerprint::Duration {
308            help: help.clone(),
309            default: default.clone(),
310        },
311        TypeSpecification::Veto { message } => TypeSpecificationFingerprint::Veto {
312            message: message.clone(),
313        },
314        TypeSpecification::Undetermined => {
315            unreachable!("fingerprint: Undetermined must not appear in a validated execution plan")
316        }
317    }
318}
319
320fn type_defining_spec_fingerprint(ds: &TypeDefiningSpec) -> TypeDefiningSpecFingerprint {
321    match ds {
322        TypeDefiningSpec::Local => TypeDefiningSpecFingerprint::Local,
323        TypeDefiningSpec::Import {
324            spec,
325            resolved_plan_hash,
326        } => TypeDefiningSpecFingerprint::Import {
327            spec_id: SpecId::new(spec.name.clone(), resolved_plan_hash.clone()).to_string(),
328            effective_from: spec.effective_from.clone(),
329        },
330    }
331}
332
333fn type_extends_fingerprint(e: &TypeExtends) -> TypeExtendsFingerprint {
334    match e {
335        TypeExtends::Primitive => TypeExtendsFingerprint::Primitive,
336        TypeExtends::Custom {
337            parent,
338            family,
339            defining_spec,
340        } => TypeExtendsFingerprint::Custom {
341            parent: parent.clone(),
342            family: family.clone(),
343            defining_spec: type_defining_spec_fingerprint(defining_spec),
344        },
345    }
346}
347
348fn lemma_type_fingerprint(lt: &LemmaType) -> LemmaTypeFingerprint {
349    LemmaTypeFingerprint {
350        name: lt.name.clone(),
351        specifications: type_spec_fingerprint(&lt.specifications),
352        extends: type_extends_fingerprint(&lt.extends),
353    }
354}
355
356fn literal_value_fingerprint(lv: &LiteralValue) -> LiteralValueFingerprint {
357    LiteralValueFingerprint {
358        value: lv.value.clone(),
359        lemma_type: lemma_type_fingerprint(&lv.lemma_type),
360    }
361}
362
363/// Project ExecutionPlan to semantic fingerprint, excluding meta and sources.
364pub fn from_plan(plan: &ExecutionPlan) -> PlanFingerprint {
365    let facts: BTreeMap<FactPath, FactFingerprint> = plan
366        .facts
367        .iter()
368        .map(|(path, data)| (path.clone(), fact_fingerprint(data)))
369        .collect();
370
371    let rules: BTreeMap<RulePath, RuleFingerprint> = plan
372        .rules
373        .iter()
374        .map(|rule| (rule.path.clone(), rule_fingerprint(rule)))
375        .collect();
376
377    let named_types: BTreeMap<String, LemmaTypeFingerprint> = plan
378        .named_types
379        .iter()
380        .map(|(k, v)| (k.clone(), lemma_type_fingerprint(v)))
381        .collect();
382
383    PlanFingerprint {
384        spec_name: plan.spec_name.clone(),
385        valid_from: plan.valid_from.clone(),
386        facts,
387        rules,
388        named_types,
389    }
390}
391
392fn fact_fingerprint(data: &FactData) -> FactFingerprint {
393    match data {
394        FactData::Value {
395            value, is_default, ..
396        } => FactFingerprint::Value {
397            value: literal_value_fingerprint(value),
398            is_default: *is_default,
399        },
400        FactData::TypeDeclaration { resolved_type, .. } => FactFingerprint::TypeDeclaration {
401            resolved_type: lemma_type_fingerprint(resolved_type),
402        },
403        FactData::SpecRef {
404            spec,
405            resolved_plan_hash,
406            ..
407        } => FactFingerprint::SpecRef {
408            spec_id: SpecId::new(spec.name.clone(), resolved_plan_hash.clone()).to_string(),
409            effective_from: spec.effective_from.clone(),
410        },
411    }
412}
413
414fn rule_fingerprint(rule: &ExecutableRule) -> RuleFingerprint {
415    RuleFingerprint {
416        path: rule.path.clone(),
417        branches: rule.branches.iter().map(branch_fingerprint).collect(),
418        needs_facts: rule.needs_facts.iter().cloned().collect(),
419        rule_type: lemma_type_fingerprint(&rule.rule_type),
420    }
421}
422
423fn branch_fingerprint(branch: &Branch) -> BranchFingerprint {
424    BranchFingerprint {
425        condition: branch.condition.as_ref().map(expression_fingerprint),
426        result: expression_fingerprint(&branch.result),
427    }
428}
429
430fn expression_fingerprint(expr: &Expression) -> ExpressionFingerprint {
431    ExpressionFingerprint {
432        kind: expression_kind_fingerprint(&expr.kind),
433    }
434}
435
436fn expression_kind_fingerprint(kind: &ExpressionKind) -> ExpressionKindFingerprint {
437    match kind {
438        ExpressionKind::Literal(lv) => {
439            ExpressionKindFingerprint::Literal(Box::new(literal_value_fingerprint(lv)))
440        }
441        ExpressionKind::FactPath(fp) => ExpressionKindFingerprint::FactPath(fp.clone()),
442        ExpressionKind::RulePath(rp) => ExpressionKindFingerprint::RulePath(rp.clone()),
443        ExpressionKind::LogicalAnd(l, r) => ExpressionKindFingerprint::LogicalAnd(
444            Box::new(expression_fingerprint(l)),
445            Box::new(expression_fingerprint(r)),
446        ),
447        ExpressionKind::Arithmetic(l, op, r) => ExpressionKindFingerprint::Arithmetic(
448            Box::new(expression_fingerprint(l)),
449            op.clone(),
450            Box::new(expression_fingerprint(r)),
451        ),
452        ExpressionKind::Comparison(l, op, r) => ExpressionKindFingerprint::Comparison(
453            Box::new(expression_fingerprint(l)),
454            op.clone(),
455            Box::new(expression_fingerprint(r)),
456        ),
457        ExpressionKind::UnitConversion(inner, target) => ExpressionKindFingerprint::UnitConversion(
458            Box::new(expression_fingerprint(inner)),
459            target.clone(),
460        ),
461        ExpressionKind::LogicalNegation(inner, nt) => ExpressionKindFingerprint::LogicalNegation(
462            Box::new(expression_fingerprint(inner)),
463            nt.clone(),
464        ),
465        ExpressionKind::MathematicalComputation(mc, inner) => {
466            ExpressionKindFingerprint::MathematicalComputation(
467                mc.clone(),
468                Box::new(expression_fingerprint(inner)),
469            )
470        }
471        ExpressionKind::Veto(ve) => ExpressionKindFingerprint::Veto(ve.clone()),
472        ExpressionKind::Now => ExpressionKindFingerprint::Now,
473        ExpressionKind::DateRelative(kind, date_expr, tol) => {
474            ExpressionKindFingerprint::DateRelative(
475                *kind,
476                Box::new(expression_fingerprint(date_expr)),
477                tol.as_ref().map(|t| Box::new(expression_fingerprint(t))),
478            )
479        }
480        ExpressionKind::DateCalendar(kind, unit, date_expr) => {
481            ExpressionKindFingerprint::DateCalendar(
482                *kind,
483                *unit,
484                Box::new(expression_fingerprint(date_expr)),
485            )
486        }
487    }
488}
489
490/// Compute deterministic 8-char hex hash from fingerprint.
491pub fn fingerprint_hash(fp: &PlanFingerprint) -> String {
492    let payload = postcard::to_allocvec(fp).expect("PlanFingerprint serialization");
493    let mut prefixed = Vec::with_capacity(FINGERPRINT_MAGIC.len() + 4 + payload.len());
494    prefixed.extend_from_slice(FINGERPRINT_MAGIC.as_slice());
495    prefixed.extend_from_slice(&FINGERPRINT_FORMAT_VERSION.to_be_bytes());
496    prefixed.extend_from_slice(&payload);
497    let digest = Sha256::digest(&prefixed);
498    let n = (u32::from(digest[0]) << 24)
499        | (u32::from(digest[1]) << 16)
500        | (u32::from(digest[2]) << 8)
501        | u32::from(digest[3]);
502    format!("{:08x}", n)
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::parsing::ast::Span;
509    use crate::parsing::source::Source;
510    use crate::planning::semantics::primitive_number;
511    use indexmap::IndexMap;
512    use std::collections::{BTreeSet, HashMap};
513
514    fn empty_plan(spec_name: &str) -> ExecutionPlan {
515        ExecutionPlan {
516            spec_name: spec_name.to_string(),
517            facts: IndexMap::new(),
518            rules: vec![],
519            meta: HashMap::new(),
520            named_types: BTreeMap::new(),
521            valid_from: None,
522            valid_to: None,
523            sources: IndexMap::new(),
524        }
525    }
526
527    fn dummy_source() -> Source {
528        Source::new(
529            "test.lemma",
530            Span {
531                start: 0,
532                end: 0,
533                line: 1,
534                col: 0,
535            },
536        )
537    }
538
539    fn literal_expr_one() -> Expression {
540        Expression::with_source(
541            ExpressionKind::Literal(Box::new(LiteralValue::number(Decimal::ONE))),
542            None,
543        )
544    }
545
546    fn simple_rule(path: RulePath, name: &str) -> ExecutableRule {
547        ExecutableRule {
548            path,
549            name: name.to_string(),
550            branches: vec![Branch {
551                condition: None,
552                result: literal_expr_one(),
553                source: dummy_source(),
554            }],
555            needs_facts: BTreeSet::new(),
556            source: dummy_source(),
557            rule_type: primitive_number().clone(),
558        }
559    }
560
561    #[test]
562    fn same_plan_same_fingerprint() {
563        let plan = empty_plan("test");
564        let fp1 = from_plan(&plan);
565        let fp2 = from_plan(&plan);
566        assert_eq!(fp1.spec_name, fp2.spec_name);
567    }
568
569    #[test]
570    fn same_plan_same_hash() {
571        let plan = empty_plan("test");
572        let h1 = fingerprint_hash(&from_plan(&plan));
573        let h2 = fingerprint_hash(&from_plan(&plan));
574        assert_eq!(h1, h2);
575    }
576
577    #[test]
578    fn different_spec_name_different_hash() {
579        let h1 = fingerprint_hash(&from_plan(&empty_plan("a")));
580        let h2 = fingerprint_hash(&from_plan(&empty_plan("b")));
581        assert_ne!(h1, h2);
582    }
583
584    /// Golden vectors for `FINGERPRINT_FORMAT_VERSION` + postcard layout. Update when bumping format.
585    #[test]
586    fn golden_plan_hash_empty_spec_names() {
587        assert_eq!(
588            fingerprint_hash(&from_plan(&empty_plan("golden_empty"))),
589            "fc4c852f"
590        );
591        assert_eq!(fingerprint_hash(&from_plan(&empty_plan("x"))), "e97e410c");
592        let mut p = empty_plan("golden_valid_from");
593        p.valid_from = Some(DateTimeValue {
594            year: 2024,
595            month: 6,
596            day: 15,
597            hour: 0,
598            minute: 0,
599            second: 0,
600            microsecond: 0,
601            timezone: None,
602        });
603        assert_eq!(fingerprint_hash(&from_plan(&p)), "b301d0c3");
604    }
605
606    #[test]
607    fn fingerprint_independent_of_fact_rule_and_type_order() {
608        let fa = FactPath::local("a".to_string());
609        let fb = FactPath::local("b".to_string());
610        let fact_a = FactData::Value {
611            value: LiteralValue::number(Decimal::ONE),
612            source: dummy_source(),
613            is_default: false,
614        };
615        let fact_b = FactData::Value {
616            value: LiteralValue::number(Decimal::from(2)),
617            source: dummy_source(),
618            is_default: false,
619        };
620
621        let type_x = LemmaType::new(
622            "age".to_string(),
623            TypeSpecification::number(),
624            TypeExtends::Primitive,
625        );
626        let type_y = LemmaType::new(
627            "weight".to_string(),
628            TypeSpecification::number(),
629            TypeExtends::Primitive,
630        );
631
632        let mut plan1 = empty_plan("order_test");
633        plan1.facts.insert(fa.clone(), fact_a.clone());
634        plan1.facts.insert(fb.clone(), fact_b.clone());
635        plan1.named_types.insert("age".to_string(), type_x.clone());
636        plan1
637            .named_types
638            .insert("weight".to_string(), type_y.clone());
639
640        let mut plan2 = empty_plan("order_test");
641        plan2.facts.insert(fb, fact_b);
642        plan2.facts.insert(fa, fact_a);
643        plan2.named_types.insert("weight".to_string(), type_y);
644        plan2.named_types.insert("age".to_string(), type_x);
645
646        let r1 = RulePath::new(vec![], "r1".to_string());
647        let r2 = RulePath::new(vec![], "r2".to_string());
648        plan1.rules = vec![simple_rule(r1.clone(), "r1"), simple_rule(r2.clone(), "r2")];
649        plan2.rules = vec![simple_rule(r2, "r2"), simple_rule(r1, "r1")];
650
651        assert_eq!(
652            fingerprint_hash(&from_plan(&plan1)),
653            fingerprint_hash(&from_plan(&plan2))
654        );
655    }
656}