Skip to main content

dupehell_core/
context.rs

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