Skip to main content

mfsk_core/msg/
hash_table.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! FT8 callsign hash table for resolving `<...>` placeholders.
3//!
4//! Ported from WSJT-X `lib/77bit/packjt77.f90` (`ihashcall`,
5//! `save_hash_call`, `hash10`, `hash12`, `hash22`).
6//!
7//! Three hash widths are used in FT8 messages:
8//! - **22-bit** — packed inside a 28-bit callsign token (Type 1 messages)
9//! - **12-bit** — Type 4 messages (one non-standard call)
10//! - **10-bit** — DXpedition RR73 messages (Type 0, n3=1)
11//!
12//! The table is populated as callsigns are decoded and used to resolve
13//! hashed callsigns in subsequent messages.
14
15use std::collections::HashMap;
16
17/// Base-38 alphabet used for callsign hashing (matches WSJT-X).
18const C38: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
19
20/// Magic constant for multiplicative hash (from WSJT-X).
21const HASH_MAGIC: u64 = 47_055_833_459;
22
23/// Maximum entries in the 22-bit LRU table.
24const MAX_HASH22: usize = 1000;
25
26/// Compute the FT8 callsign hash at a given bit width.
27///
28/// The callsign is left-padded to 11 characters, converted to a base-38
29/// number, multiplied by a magic constant, then the top `m` bits are
30/// extracted.
31///
32/// # Arguments
33/// * `call` — callsign (up to 11 chars, will be uppercased and padded)
34/// * `m` — bit width: 10, 12, or 22
35pub fn ihashcall(call: &str, m: u32) -> u32 {
36    let call = call.to_ascii_uppercase();
37    let bytes = call.as_bytes();
38
39    let mut n64: u64 = 0;
40    for i in 0..11 {
41        let c = if i < bytes.len() { bytes[i] } else { b' ' };
42        let j = C38.iter().position(|&x| x == c).unwrap_or(0);
43        n64 = n64.wrapping_mul(38).wrapping_add(j as u64);
44    }
45
46    let hash64 = n64.wrapping_mul(HASH_MAGIC);
47    (hash64 >> (64 - m)) as u32
48}
49
50/// Runtime callsign hash lookup table.
51///
52/// Populated during decoding; used to resolve `<...>` placeholders in
53/// messages containing hashed callsigns.
54#[derive(Debug, Clone)]
55pub struct CallsignHashTable {
56    /// 10-bit hash → callsign (direct-indexed, 1024 slots)
57    hash10: HashMap<u32, String>,
58    /// 12-bit hash → callsign (direct-indexed, 4096 slots)
59    hash12: HashMap<u32, String>,
60    /// 22-bit hash → callsign (LRU, max 1000 entries)
61    hash22: Vec<(u32, String)>,
62}
63
64impl CallsignHashTable {
65    /// Create an empty hash table.
66    pub fn new() -> Self {
67        Self {
68            hash10: HashMap::new(),
69            hash12: HashMap::new(),
70            hash22: Vec::new(),
71        }
72    }
73
74    /// Register a decoded callsign, populating all three hash tables.
75    ///
76    /// Skips empty strings, `<...>` placeholders, and strings shorter than
77    /// 3 characters. Strips `<>` brackets if present.
78    pub fn insert(&mut self, call: &str) {
79        let call = call.trim();
80        // Strip angle brackets
81        let call = call.strip_prefix('<').unwrap_or(call);
82        let call = call.strip_suffix('>').unwrap_or(call);
83        // Strip /R or /P suffix for hashing
84        let base = if call.ends_with("/R") || call.ends_with("/P") {
85            &call[..call.len() - 2]
86        } else {
87            call
88        };
89
90        if base.len() < 2 || base == "..." || base.starts_with("CQ") {
91            return;
92        }
93
94        let n10 = ihashcall(base, 10);
95        let n12 = ihashcall(base, 12);
96        let n22 = ihashcall(base, 22);
97
98        self.hash10.insert(n10, base.to_string());
99        self.hash12.insert(n12, base.to_string());
100
101        // 22-bit: LRU update
102        if let Some(pos) = self.hash22.iter().position(|(h, _)| *h == n22) {
103            // Move to front
104            let entry = self.hash22.remove(pos);
105            self.hash22.insert(0, entry);
106        } else {
107            self.hash22.insert(0, (n22, base.to_string()));
108            if self.hash22.len() > MAX_HASH22 {
109                self.hash22.pop();
110            }
111        }
112    }
113
114    /// Look up a 10-bit hash. Returns the callsign if found.
115    pub fn lookup10(&self, n10: u32) -> Option<&str> {
116        self.hash10.get(&n10).map(|s| s.as_str())
117    }
118
119    /// Look up a 12-bit hash. Returns the callsign if found.
120    pub fn lookup12(&self, n12: u32) -> Option<&str> {
121        self.hash12.get(&n12).map(|s| s.as_str())
122    }
123
124    /// Look up a 22-bit hash. Returns the callsign wrapped in `<>` if found,
125    /// matching WSJT-X convention (e.g. `<JA1ABC>`).
126    pub fn lookup22(&self, n22: u32) -> Option<String> {
127        self.hash22
128            .iter()
129            .find(|(h, _)| *h == n22)
130            .map(|(_, call)| format!("<{}>", call))
131    }
132
133    /// Clear all entries.
134    pub fn clear(&mut self) {
135        self.hash10.clear();
136        self.hash12.clear();
137        self.hash22.clear();
138    }
139
140    /// Number of entries in the 22-bit table (for diagnostics).
141    pub fn len22(&self) -> usize {
142        self.hash22.len()
143    }
144}
145
146impl Default for CallsignHashTable {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152// ── Tests ──────────────────────────────────────────────────────────────────
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn hash_basic() {
160        // Verify hash values are deterministic and non-zero
161        let h22 = ihashcall("JA1ABC", 22);
162        let h12 = ihashcall("JA1ABC", 12);
163        let h10 = ihashcall("JA1ABC", 10);
164        assert!(h22 < (1 << 22));
165        assert!(h12 < (1 << 12));
166        assert!(h10 < (1 << 10));
167        // Same input → same output
168        assert_eq!(h22, ihashcall("JA1ABC", 22));
169    }
170
171    #[test]
172    fn insert_and_lookup() {
173        let mut t = CallsignHashTable::new();
174        t.insert("JA1ABC");
175        t.insert("3Y0Z");
176
177        let h22 = ihashcall("JA1ABC", 22);
178        let h12 = ihashcall("JA1ABC", 12);
179        let h10 = ihashcall("JA1ABC", 10);
180
181        assert_eq!(t.lookup22(h22), Some("<JA1ABC>".to_string()));
182        assert_eq!(t.lookup12(h12), Some("JA1ABC"));
183        assert_eq!(t.lookup10(h10), Some("JA1ABC"));
184
185        let h22z = ihashcall("3Y0Z", 22);
186        assert_eq!(t.lookup22(h22z), Some("<3Y0Z>".to_string()));
187    }
188
189    #[test]
190    fn lru_eviction() {
191        let mut t = CallsignHashTable::new();
192        // Fill beyond MAX_HASH22
193        for i in 0..MAX_HASH22 + 10 {
194            t.insert(&format!("T{:04}X", i));
195        }
196        assert_eq!(t.len22(), MAX_HASH22);
197    }
198
199    #[test]
200    fn skip_special() {
201        let mut t = CallsignHashTable::new();
202        t.insert("<...>");
203        t.insert("CQ");
204        t.insert("CQ DX");
205        t.insert("");
206        t.insert("A"); // too short
207        assert_eq!(t.len22(), 0);
208    }
209
210    #[test]
211    fn strip_suffix() {
212        let mut t = CallsignHashTable::new();
213        t.insert("JA1ABC/P");
214        let h22 = ihashcall("JA1ABC", 22);
215        assert_eq!(t.lookup22(h22), Some("<JA1ABC>".to_string()));
216    }
217}