Skip to main content

ad_astra/analysis/
closeness.rs

1////////////////////////////////////////////////////////////////////////////////
2// This file is part of "Ad Astra", an embeddable scripting programming       //
3// language platform.                                                         //
4//                                                                            //
5// This work is proprietary software with source-available code.              //
6//                                                                            //
7// To copy, use, distribute, or contribute to this work, you must agree to    //
8// the terms of the General License Agreement:                                //
9//                                                                            //
10// https://github.com/Eliah-Lakhin/ad-astra/blob/master/EULA.md               //
11//                                                                            //
12// The agreement grants a Basic Commercial License, allowing you to use       //
13// this work in non-commercial and limited commercial products with a total   //
14// gross revenue cap. To remove this commercial limit for one of your         //
15// products, you must acquire a Full Commercial License.                      //
16//                                                                            //
17// If you contribute to the source code, documentation, or related materials, //
18// you must grant me an exclusive license to these contributions.             //
19// Contributions are governed by the "Contributions" section of the General   //
20// License Agreement.                                                         //
21//                                                                            //
22// Copying the work in parts is strictly forbidden, except as permitted       //
23// under the General License Agreement.                                       //
24//                                                                            //
25// If you do not or cannot agree to the terms of this Agreement,              //
26// do not use this work.                                                      //
27//                                                                            //
28// This work is provided "as is", without any warranties, express or implied, //
29// except where such disclaimers are legally invalid.                         //
30//                                                                            //
31// Copyright (c) 2024 Ilya Lakhin (Илья Александрович Лахин).                 //
32// All rights reserved.                                                       //
33////////////////////////////////////////////////////////////////////////////////
34
35use std::{
36    cmp::Ordering,
37    fmt::{Debug, Display, Formatter},
38    hash::{Hash, Hasher},
39};
40
41use strsim::normalized_damerau_levenshtein;
42
43const EPSILON: f32 = 0.0001;
44
45/// A score representing the distance between two strings.
46///
47/// The score is measured in terms of percentage with fractional precision.
48///
49/// "100%" indicates that the estimated string exactly matches the pattern
50/// string, while "0%" indicates they are completely distinct.
51///
52/// The Debug and Display implementations of this object round the underlying
53/// percentage to the nearest integer. The default value is "0%".
54///
55/// The [StringEstimation::estimate] function estimates the distance
56/// between two strings and returns a `Closeness` value.
57#[repr(transparent)]
58#[derive(Clone, Copy)]
59pub struct Closeness(f32);
60
61impl Debug for Closeness {
62    #[inline(always)]
63    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
64        Display::fmt(self, formatter)
65    }
66}
67
68impl Display for Closeness {
69    #[inline(always)]
70    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
71        formatter.write_fmt(format_args!("{}%", self.percents()))
72    }
73}
74
75impl PartialEq for Closeness {
76    #[inline(always)]
77    fn eq(&self, other: &Self) -> bool {
78        self.normalized().eq(&other.normalized())
79    }
80}
81
82impl Eq for Closeness {}
83
84impl PartialOrd for Closeness {
85    #[inline(always)]
86    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
87        Some(self.cmp(other))
88    }
89}
90
91impl Ord for Closeness {
92    #[inline]
93    fn cmp(&self, other: &Self) -> Ordering {
94        self.normalized().cmp(&other.normalized())
95    }
96}
97
98impl Hash for Closeness {
99    #[inline(always)]
100    fn hash<H: Hasher>(&self, state: &mut H) {
101        self.normalized().hash(state)
102    }
103}
104
105impl Default for Closeness {
106    #[inline(always)]
107    fn default() -> Self {
108        Self::zero()
109    }
110}
111
112impl Closeness {
113    /// Returns a "0%" closeness value.
114    #[inline(always)]
115    pub const fn zero() -> Self {
116        Self(0.0)
117    }
118
119    /// Returns a "50%" closeness value.
120    #[inline(always)]
121    pub const fn half() -> Self {
122        Self(0.5)
123    }
124
125    /// Returns a "100%" closeness value.
126    #[inline(always)]
127    pub const fn one() -> Self {
128        Self(1.0)
129    }
130
131    /// Returns the underlying percentage value rounded up to the nearest
132    /// integer.
133    #[inline(always)]
134    pub fn percents(self) -> u16 {
135        ((self.0 * 1000.0).round() / 10.0) as u16
136    }
137
138    #[inline(always)]
139    fn normalized(self) -> u32 {
140        (self.0 / EPSILON) as u32
141    }
142}
143
144/// An extension trait for strings that estimates the distance between two
145/// strings.
146pub trait StringEstimation {
147    /// Estimates the similarity between two strings.
148    ///
149    /// The returned [Closeness] object represents the similarity between
150    /// the provided string and the specified `pattern` in terms of percentage,
151    /// with fractional precision. A value of "100%" ([Closeness::one]) means
152    /// that the string fully matches the pattern.
153    ///
154    /// ```rust
155    /// use ad_astra::analysis::{Closeness, StringEstimation};
156    ///
157    /// assert_eq!("foo".estimate("foo"), Closeness::one());
158    /// assert_eq!("foo".estimate("aaa"), Closeness::zero());
159    ///
160    /// println!("{}", "foo".estimate("Foo")); // ~ 66%
161    /// println!("{}", "Bra".estimate("bar")); // ~ 33%
162    /// ```
163    fn estimate(&self, pattern: impl AsRef<str>) -> Closeness;
164}
165
166impl<S: AsRef<str>> StringEstimation for S {
167    fn estimate(&self, pattern: impl AsRef<str>) -> Closeness {
168        let this = self.as_ref();
169        let pattern = pattern.as_ref();
170
171        let closeness = normalized_damerau_levenshtein(pattern, this);
172
173        Closeness((closeness as f32 / EPSILON) as usize as f32 * EPSILON)
174    }
175}