Skip to main content

dupehell_core/
context.rs

1// DupeHell -- MIT License
2//
3// Synthetic multi-domain dataset generator for record linkage benchmarking.
4// No liability for misuse.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9/// A loaded pool entry — flat list of strings.
10pub type Pool = Vec<String>;
11
12/// All pools keyed by name.
13#[derive(Clone, Debug)]
14pub struct PoolStore {
15    pub pools: HashMap<String, Pool>,
16}
17
18/// Known locale codes supported by pool files.
19const LOCALES: &[&str] = &["en", "fr", "de", "es", "it", "pt"];
20
21fn is_locale_key(key: &str) -> bool {
22    LOCALES.contains(&key)
23}
24
25impl PoolStore {
26    /// Load all JSON files from `pools_dir` (non-recursive), selecting `locale` data.
27    /// Falls back to `"en"` if the requested locale is not found.
28    pub fn load(pools_dir: &str, locale: &str) -> Result<Self, String> {
29        let dir = Path::new(pools_dir);
30        if !dir.is_dir() {
31            return Err(format!("pools dir not found: {pools_dir}"));
32        }
33        let mut pools = HashMap::new();
34        let mut entries: Vec<_> = std::fs::read_dir(dir)
35            .map_err(|e| format!("cannot read {pools_dir}: {e}"))?
36            .filter_map(|r| r.ok())
37            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
38            .collect();
39        entries.sort_by_key(|e| e.file_name());
40
41        for entry in entries {
42            let name = entry
43                .path()
44                .file_stem()
45                .unwrap()
46                .to_string_lossy()
47                .to_string();
48            // `from_reader` on a raw `File` reads via small unbuffered calls
49            // (no internal buffering in serde_json) — reading the whole
50            // small pool file into memory first and parsing with `from_str`
51            // is markedly faster, and this runs once per pool file (132 of
52            // them) at every startup.
53            let raw = std::fs::read_to_string(entry.path())
54                .map_err(|e| format!("cannot open {name}.json: {e}"))?;
55            let data: serde_json::Value =
56                serde_json::from_str(&raw).map_err(|e| format!("cannot parse {name}.json: {e}"))?;
57
58            match &data {
59                serde_json::Value::Array(arr) => {
60                    let pool = arr
61                        .iter()
62                        .filter_map(|v| v.as_str())
63                        .map(String::from)
64                        .collect::<Pool>();
65                    pools.insert(name, pool);
66                }
67                serde_json::Value::Object(_map) => {
68                    let pool = if let Some(arr) = data
69                        .get(locale)
70                        .or_else(|| data.get("en"))
71                        .and_then(|v| v.as_array())
72                    {
73                        arr.iter()
74                            .filter_map(|v| v.as_str())
75                            .map(String::from)
76                            .collect::<Pool>()
77                    } else if let Some(obj) = data.as_object() {
78                        // No locale key found — check if this is a region-keyed pool
79                        // (e.g. french_cities.json with "ile_de_france" keys).
80                        let has_locale_keys = obj.keys().any(|k| is_locale_key(k));
81                        if !has_locale_keys {
82                            obj.values()
83                                .filter_map(|v| v.as_array())
84                                .flatten()
85                                .filter_map(|v| {
86                                    v.as_str().or_else(|| v.get(0).and_then(|s| s.as_str()))
87                                })
88                                .map(String::from)
89                                .collect::<Pool>()
90                        } else {
91                            Vec::new()
92                        }
93                    } else {
94                        Vec::new()
95                    };
96                    if !pool.is_empty() {
97                        pools.insert(name, pool);
98                    }
99                }
100                _ => {}
101            };
102        }
103        Ok(Self { pools })
104    }
105
106    pub fn get(&self, name: &str) -> Option<&Pool> {
107        self.pools.get(name)
108    }
109}
110
111/// The engine context holding config and pool data.
112#[derive(Clone, Debug)]
113pub struct Context {
114    pub pool_store: PoolStore,
115    pub locale: String,
116    pub watermark_map: std::collections::HashMap<u64, u64>, // col_tag → masked value
117}
118
119impl Context {
120    const WATERMARK_SECRET: &'static str = "DupeHell-WATERMARK-v0.4-educational-only-2026";
121
122    /// Build a Context from a domain name + locale + path to pools directory.
123    pub fn new(domain: &str, locale: &str, pools_dir: &str) -> Result<Self, String> {
124        let pool_store = PoolStore::load(pools_dir, locale)?;
125        log::info!(
126            "Context loaded: domain={domain}, locale={locale}, pools={}",
127            pool_store.pools.len()
128        );
129        Ok(Self {
130            pool_store,
131            locale: locale.to_string(),
132            watermark_map: std::collections::HashMap::new(),
133        })
134    }
135
136    /// Enable watermarking by computing per-column tags from the pipeline config.
137    pub fn enable_watermark(&mut self, domain: &str, size: usize, seed: u64) {
138        use sha2::{Digest, Sha256};
139        for &tag in &[
140            0x53534e,   // "SSN"
141            0x50484f4e, // "PHONE"
142            0x50414e,   // "PAN"
143            0x4d4544,   // "MEDICARE"
144            0x4f4643,   // "OFFICE_PHONE"
145            0x504153,   // "PASSPORT"
146            0x414354,   // "ACCOUNT"
147            0x424152,   // "BARCODE"
148            0x494343,   // "ICCID"
149            0x555043,   // "UPC"
150        ] {
151            let input = format!(
152                "{}{}{}{}{}",
153                Self::WATERMARK_SECRET,
154                domain,
155                size,
156                seed,
157                tag
158            );
159            let hash = Sha256::digest(input.as_bytes());
160            let wm = u64::from_le_bytes(hash[..8].try_into().unwrap());
161            self.watermark_map.insert(tag, wm);
162        }
163    }
164
165    /// Return the watermark mask for a given column tag (last 3 digits, 0..999).
166    pub fn watermark_3digits(&self, tag: u64) -> u64 {
167        self.watermark_map.get(&tag).copied().unwrap_or(0) % 1000
168    }
169
170    /// Return the watermark mask for a given column tag (last 2 digits, 0..99).
171    pub fn watermark_2digits(&self, tag: u64) -> u64 {
172        self.watermark_map.get(&tag).copied().unwrap_or(0) % 100
173    }
174
175    /// Create a minimal context for testing (no pools loaded, watermark disabled).
176    /// Watermark helpers return 0, ensuring deterministic test output.
177    #[cfg(test)]
178    pub fn test() -> Self {
179        Self {
180            pool_store: PoolStore {
181                pools: HashMap::new(),
182            },
183            locale: "en".to_string(),
184            watermark_map: HashMap::new(),
185        }
186    }
187}