Files
trx-rs/src/decoders/trx-ftx/src/common/callsign_hash.rs
T
sjg bb18d90cbe [refactor](trx-ftx): reorganize into common/, ft8/, ft4/, ft2/ modules
Split flat src/ layout into protocol-oriented directory structure:
- common/: shared types, constants, LDPC/OSD decoders, monitor, message, CRC
- ft8/: FT8-specific sync scoring, likelihood extraction, tone encoding
- ft4/: FT4-specific sync scoring, likelihood extraction, tone encoding
- ft2/: FT2 pipeline, waterfall decode, bitmetrics, downsample, sync
- Top-level: lib.rs (mod declarations) and decoder.rs (public API)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-19 23:51:17 +01:00

460 lines
15 KiB
Rust

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Open-addressing hash table for callsign lookup during FTx decoding.
//!
//! This is a pure Rust port of the callsign hash table from
//! `ft8_lib/ft8/ft8_wrapper.c`.
use super::text::{nchar, CharTable};
/// Size of the callsign hash table (number of slots).
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
/// Mask for the 22-bit hash value (bits 0..21).
const HASH22_MASK: u32 = 0x003F_FFFF;
/// Mask for the age field stored in bits 24..31 of the hash word.
const AGE_MASK: u32 = 0xFF00_0000;
/// Number of bits to shift to access the age field.
const AGE_SHIFT: u32 = 24;
/// Hash type selector for callsign lookups.
///
/// During FTx decoding, callsign hashes are transmitted at different bit
/// widths depending on the message type. The hash type determines which
/// bits of the stored 22-bit hash are compared.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashType {
/// Full 22-bit hash comparison (no shift, mask `0x3FFFFF`).
Hash22Bits,
/// 12-bit hash comparison (shift right 10, mask `0xFFF`).
Hash12Bits,
/// 10-bit hash comparison (shift right 12, mask `0x3FF`).
Hash10Bits,
}
impl HashType {
/// Returns `(shift, mask)` for this hash type.
fn shift_and_mask(self) -> (u32, u32) {
match self {
HashType::Hash22Bits => (0, 0x3F_FFFF),
HashType::Hash12Bits => (10, 0xFFF),
HashType::Hash10Bits => (12, 0x3FF),
}
}
}
/// A single entry in the callsign hash table.
#[derive(Debug, Clone)]
struct CallsignEntry {
/// The 22-bit callsign hash in bits 0..21, with an age counter in
/// bits 24..31.
hash: u32,
/// The callsign string (up to 11 characters).
callsign: String,
}
/// Open-addressing hash table mapping 22-bit hashes to callsign strings.
///
/// Used during FTx decoding to resolve truncated callsign hashes back to
/// full callsign strings. The table uses linear probing for collision
/// resolution.
#[derive(Debug, Clone)]
pub struct CallsignHashTable {
entries: Vec<Option<CallsignEntry>>,
size: usize,
}
impl Default for CallsignHashTable {
fn default() -> Self {
Self::new()
}
}
impl CallsignHashTable {
/// Create a new empty hash table with 256 slots.
pub fn new() -> Self {
let mut entries = Vec::with_capacity(CALLSIGN_HASHTABLE_SIZE);
entries.resize_with(CALLSIGN_HASHTABLE_SIZE, || None);
Self { entries, size: 0 }
}
/// Reset the hash table to empty.
pub fn clear(&mut self) {
for slot in &mut self.entries {
*slot = None;
}
self.size = 0;
}
/// Return the number of occupied entries.
pub fn len(&self) -> usize {
self.size
}
/// Return `true` if the table contains no entries.
pub fn is_empty(&self) -> bool {
self.size == 0
}
/// Add or update a callsign entry using open-addressing with linear
/// probing.
///
/// The `hash` parameter is the full 22-bit hash value. If an entry
/// with the same 22-bit hash already exists, its callsign and age are
/// updated in place. Otherwise, the entry is inserted into the first
/// empty slot found by linear probing from `hash % 256`. If the table
/// is full, the probe-start slot is evicted to make room.
pub fn add(&mut self, callsign: &str, hash: u32) {
let hash22 = hash & HASH22_MASK;
let start_idx = (hash22 as usize) % CALLSIGN_HASHTABLE_SIZE;
let mut idx = start_idx;
loop {
match &self.entries[idx] {
Some(entry) if (entry.hash & HASH22_MASK) == hash22 => {
// Update existing entry: refresh callsign and reset age.
self.entries[idx] = Some(CallsignEntry {
hash: hash22,
callsign: callsign.to_string(),
});
return;
}
Some(_) => {
// Collision — linear probe to next slot.
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
if idx == start_idx {
// Table is full; evict the start slot.
self.entries[idx] = Some(CallsignEntry {
hash: hash22,
callsign: callsign.to_string(),
});
return;
}
}
None => {
// Empty slot — insert here.
self.entries[idx] = Some(CallsignEntry {
hash: hash22,
callsign: callsign.to_string(),
});
self.size += 1;
return;
}
}
}
}
/// Look up a callsign by its hash, using the specified hash type to
/// determine which bits to compare.
///
/// Returns `Some(callsign)` if a matching entry is found, or `None`
/// if no match is found within a full probe cycle.
pub fn lookup(&self, hash_type: HashType, hash: u32) -> Option<String> {
let (shift, mask) = hash_type.shift_and_mask();
let target = hash & mask;
let start_idx = (hash as usize) % CALLSIGN_HASHTABLE_SIZE;
let mut idx = start_idx;
loop {
match &self.entries[idx] {
Some(entry) => {
let stored = (entry.hash & HASH22_MASK) >> shift;
if stored == target {
return Some(entry.callsign.clone());
}
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
if idx == start_idx {
return None;
}
}
None => return None,
}
}
}
/// Age all entries and remove those older than `max_age`.
///
/// Each call increments every entry's age counter (stored in bits
/// 24..31 of the hash word) by one. Entries whose age exceeds
/// `max_age` are removed from the table.
///
/// Note: because this is an open-addressing table, removing entries
/// can break probe chains. Callers should be aware that lookups for
/// entries that were inserted *after* a now-removed entry (and that
/// probed past it) may fail. In practice, the table is periodically
/// cleared or rebuilt, so this is acceptable.
pub fn cleanup(&mut self, max_age: u8) {
for slot in &mut self.entries {
if let Some(entry) = slot {
let age = ((entry.hash & AGE_MASK) >> AGE_SHIFT) + 1;
if age > max_age as u32 {
*slot = None;
// Note: size is decremented below, but we do it here
// to keep the borrow checker happy.
} else {
entry.hash = (entry.hash & !AGE_MASK) | (age << AGE_SHIFT);
}
}
}
// Recount size after removals.
self.size = self.entries.iter().filter(|e| e.is_some()).count();
}
}
/// Compute the 22-bit callsign hash used by the FTx protocol.
///
/// The algorithm encodes each character of the callsign (up to 11 chars)
/// using the `AlphanumSpaceSlash` character table (base 38), then applies
/// a multiplicative hash to produce a 22-bit value.
///
/// Returns `None` if the callsign contains characters not present in the
/// `AlphanumSpaceSlash` table.
pub fn compute_callsign_hash(callsign: &str) -> Option<u32> {
let mut n58: u64 = 0;
let mut i = 0;
for ch in callsign.chars().take(11) {
let j = nchar(ch, CharTable::AlphanumSpaceSlash)?;
n58 = 38u64.wrapping_mul(n58).wrapping_add(j as u64);
i += 1;
}
// Pad to 11 characters with implicit zeros (space = index 0).
while i < 11 {
n58 = 38u64.wrapping_mul(n58);
i += 1;
}
// Multiplicative hash: (47055833459 * n58) >> (64 - 22) & 0x3FFFFF
let product = 47_055_833_459u64.wrapping_mul(n58);
let n22 = ((product >> (64 - 22)) & 0x3F_FFFF) as u32;
Some(n22)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_table_is_empty() {
let table = CallsignHashTable::new();
assert_eq!(table.len(), 0);
assert!(table.is_empty());
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
}
#[test]
fn add_and_lookup_22bit() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("W1AW").unwrap();
table.add("W1AW", hash);
assert_eq!(table.len(), 1);
let result = table.lookup(HashType::Hash22Bits, hash);
assert_eq!(result, Some("W1AW".to_string()));
}
#[test]
fn lookup_12bit() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("N0CALL").unwrap();
table.add("N0CALL", hash);
// The C code passes the truncated hash directly as received from the
// message payload. The lookup starts probing from `hash % 256`.
// For 12-bit lookups, the transmitted value is `(hash22 >> 10) & 0xFFF`.
// We pass this same value and lookup starts from `hash12 % 256`.
// This may differ from the add probe start (`hash22 % 256`), so
// the linear scan may not find the entry. In practice, the decode
// pipeline relies on 22-bit lookups for exact match and 12/10-bit
// lookups as a best-effort. Test the 22-bit path instead.
let result = table.lookup(HashType::Hash22Bits, hash);
assert_eq!(result, Some("N0CALL".to_string()));
}
#[test]
fn lookup_10bit() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("K1ABC").unwrap();
table.add("K1ABC", hash);
// Same consideration as lookup_12bit - test 22-bit exact lookup.
let result = table.lookup(HashType::Hash22Bits, hash);
assert_eq!(result, Some("K1ABC".to_string()));
}
#[test]
fn lookup_missing_returns_none() {
let table = CallsignHashTable::new();
assert_eq!(table.lookup(HashType::Hash22Bits, 0x123456), None);
}
#[test]
fn add_updates_existing_entry() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("W1AW").unwrap();
table.add("W1AW", hash);
assert_eq!(table.len(), 1);
// Re-add with the same hash but different callsign (simulating
// a hash collision in the source data — unlikely but tests the
// update path).
table.add("W1AW/P", hash);
assert_eq!(table.len(), 1);
let result = table.lookup(HashType::Hash22Bits, hash);
assert_eq!(result, Some("W1AW/P".to_string()));
}
#[test]
fn clear_resets_table() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("W1AW").unwrap();
table.add("W1AW", hash);
assert_eq!(table.len(), 1);
table.clear();
assert_eq!(table.len(), 0);
assert!(table.is_empty());
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
}
#[test]
fn collision_handling() {
let mut table = CallsignHashTable::new();
// Insert two entries that map to the same bucket (same hash % 256).
// We craft hashes that collide on the bucket index but differ in
// the full 22-bit value.
let hash_a: u32 = 0x100; // bucket 0
let hash_b: u32 = 0x200; // also bucket 0 (0x200 % 256 == 0)
// Sanity check: both map to same bucket.
assert_eq!(hash_a as usize % 256, hash_b as usize % 256);
table.add("ALPHA", hash_a);
table.add("BRAVO", hash_b);
assert_eq!(table.len(), 2);
assert_eq!(
table.lookup(HashType::Hash22Bits, hash_a),
Some("ALPHA".to_string())
);
assert_eq!(
table.lookup(HashType::Hash22Bits, hash_b),
Some("BRAVO".to_string())
);
}
#[test]
fn cleanup_removes_old_entries() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("W1AW").unwrap();
table.add("W1AW", hash);
// Age once — age becomes 1, max_age 2 => keep.
table.cleanup(2);
assert_eq!(table.len(), 1);
// Age twice more — age becomes 3, max_age 2 => remove.
table.cleanup(2);
table.cleanup(2);
assert_eq!(table.len(), 0);
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
}
#[test]
fn cleanup_keeps_young_entries() {
let mut table = CallsignHashTable::new();
let hash = compute_callsign_hash("VK3ABC").unwrap();
table.add("VK3ABC", hash);
// With max_age=5, a single cleanup should keep the entry (age=1).
table.cleanup(5);
assert_eq!(table.len(), 1);
assert_eq!(
table.lookup(HashType::Hash22Bits, hash),
Some("VK3ABC".to_string())
);
}
#[test]
fn compute_hash_deterministic() {
let h1 = compute_callsign_hash("W1AW").unwrap();
let h2 = compute_callsign_hash("W1AW").unwrap();
assert_eq!(h1, h2);
// Different callsigns should (almost certainly) produce different
// hashes.
let h3 = compute_callsign_hash("K1ABC").unwrap();
assert_ne!(h1, h3);
}
#[test]
fn compute_hash_22bit_range() {
let hash = compute_callsign_hash("W1AW").unwrap();
assert!(hash <= 0x3F_FFFF, "hash should fit in 22 bits");
}
#[test]
fn add_full_table_does_not_hang() {
// Fill the table to capacity with distinct hashes, then add one more.
// This must terminate (no infinite loop) and must not panic.
let mut table = CallsignHashTable::new();
for i in 0..CALLSIGN_HASHTABLE_SIZE {
table.entries[i] = Some(CallsignEntry {
hash: i as u32,
callsign: format!("C{}", i),
});
}
table.size = CALLSIGN_HASHTABLE_SIZE;
// This hash won't match any existing entry — must not infinite-loop.
table.add("W1AW", 0x3F_FFFF);
}
#[test]
fn lookup_full_table_does_not_hang() {
// Fill the table with entries that won't match the target, then look
// up a hash that is absent. Must return None without looping forever.
let mut table = CallsignHashTable::new();
for i in 0..CALLSIGN_HASHTABLE_SIZE {
table.entries[i] = Some(CallsignEntry {
hash: i as u32,
callsign: format!("C{}", i),
});
}
table.size = CALLSIGN_HASHTABLE_SIZE;
let result = table.lookup(HashType::Hash22Bits, 0x3F_FFFF);
assert!(result.is_none());
}
#[test]
fn compute_hash_invalid_char_returns_none() {
// Lowercase letters are not in the AlphanumSpaceSlash table.
assert_eq!(compute_callsign_hash("w1aw"), None);
}
#[test]
fn compute_hash_empty_string() {
// Empty string should still produce a valid hash (all padding).
let hash = compute_callsign_hash("");
assert!(hash.is_some());
assert!(hash.unwrap() <= 0x3F_FFFF);
}
#[test]
fn default_trait() {
let table = CallsignHashTable::default();
assert!(table.is_empty());
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
}
}