gaze-types 0.17.0

Shared value contracts for Gaze
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Closed label vocabulary, class map and operating point for the Nym-small safety net.
//!
//! The Nym-small model (`Wismut/nym-pii-multilingual-small`) labels 40 entity types. Only the
//! labels that carry a Gaze class can ever be enabled, and every enabled label needs an explicit
//! threshold. Both rules are checked when the operating point is built, so a policy that names an
//! unknown or unmapped label fails at load time instead of being skipped at inference time.

use std::collections::BTreeMap;
use std::fmt;

use thiserror::Error;

use crate::{PiiClass, SafetyNetPiiClass};

/// Stable safety-net identifier recorded on every Nym suspect and audit row.
pub const NYM_SAFETY_NET_ID: &str = "nym-small-int8";

macro_rules! nym_labels {
    ($($variant:ident => $name:literal,)+) => {
        /// The 40 entity types of the pinned Nym-small classifier, in `id2label` order.
        ///
        /// Classifier id `2k + 1` is `B-` and `2k + 2` is `I-` of the label at index `k`; id 0
        /// is `O`.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
        pub enum NymLabel {
            $(
                #[doc = concat!("`", $name, "`.")]
                $variant,
            )+
        }

        impl NymLabel {
            /// Every label in classifier order.
            pub const ALL: &'static [NymLabel] = &[$(Self::$variant,)+];

            /// Upstream label spelling.
            pub fn as_str(self) -> &'static str {
                match self {
                    $(Self::$variant => $name,)+
                }
            }

            /// Parses an upstream label spelling. Matching is exact.
            pub fn parse(value: &str) -> Option<Self> {
                match value {
                    $($name => Some(Self::$variant),)+
                    _ => None,
                }
            }
        }
    };
}

nym_labels! {
    AccountNumber => "ACCOUNT_NUMBER",
    Age => "AGE",
    ApiKey => "API_KEY",
    BuildingNumber => "BUILDING_NUMBER",
    City => "CITY",
    CompanyName => "COMPANY_NAME",
    Country => "COUNTRY",
    CreditDebitCard => "CREDIT_DEBIT_CARD",
    CustomerId => "CUSTOMER_ID",
    Cvv => "CVV",
    Date => "DATE",
    DateOfBirth => "DATE_OF_BIRTH",
    DriversLicense => "DRIVERS_LICENSE",
    Email => "EMAIL",
    EmployeeId => "EMPLOYEE_ID",
    FaxNumber => "FAX_NUMBER",
    Gender => "GENDER",
    GivenName => "GIVEN_NAME",
    GovernmentId => "GOVERNMENT_ID",
    Iban => "IBAN",
    LicensePlate => "LICENSE_PLATE",
    MacAddress => "MAC_ADDRESS",
    MedicalRecordNumber => "MEDICAL_RECORD_NUMBER",
    Passport => "PASSPORT",
    Password => "PASSWORD",
    Phone => "PHONE",
    Pin => "PIN",
    RoutingNumber => "ROUTING_NUMBER",
    SecondaryAddress => "SECONDARY_ADDRESS",
    Ssn => "SSN",
    State => "STATE",
    StreetAddress => "STREET_ADDRESS",
    StreetName => "STREET_NAME",
    Surname => "SURNAME",
    SwiftBic => "SWIFT_BIC",
    TaxId => "TAX_ID",
    Time => "TIME",
    Url => "URL",
    Username => "USERNAME",
    ZipCode => "ZIP_CODE",
}

impl fmt::Display for NymLabel {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// Maps a Nym label into the closed safety-net class set.
///
/// Eight labels carry a class. `GIVEN_NAME` and `SURNAME` explicitly map to `Name` so the
/// backstop can protect person names regardless of capitalization. This is a deliberate
/// extension of the former six-label map, not a generic fallback: every other unsupported
/// label still fails policy loading rather than being folded into `Name`.
pub fn nym_label_to_safety_net_class(label: NymLabel) -> Result<SafetyNetPiiClass, NymConfigError> {
    match label {
        NymLabel::BuildingNumber => Ok(SafetyNetPiiClass::BuildingNumber),
        NymLabel::LicensePlate => Ok(SafetyNetPiiClass::LicensePlate),
        NymLabel::Username => Ok(SafetyNetPiiClass::Username),
        NymLabel::DateOfBirth => Ok(SafetyNetPiiClass::Date),
        NymLabel::ZipCode => Ok(SafetyNetPiiClass::PostalCode),
        NymLabel::TaxId => Ok(SafetyNetPiiClass::TaxId),
        NymLabel::GivenName | NymLabel::Surname => Ok(SafetyNetPiiClass::Name),
        unsupported => Err(NymConfigError::UnsupportedLabel {
            label: unsupported.as_str().to_string(),
        }),
    }
}

/// Convenience: Nym label to Gaze `PiiClass`.
pub fn nym_label_to_pii_class(label: NymLabel) -> Result<PiiClass, NymConfigError> {
    nym_label_to_safety_net_class(label).map(SafetyNetPiiClass::to_pii_class)
}

/// Why a Nym operating point was rejected.
#[derive(Debug, Clone, PartialEq, Error)]
#[non_exhaustive]
pub enum NymConfigError {
    /// The label is not one of the 40 Nym labels.
    #[error("unknown nym label `{label}`")]
    UnknownLabel {
        /// The rejected spelling.
        label: String,
    },
    /// The label exists but carries no Gaze class, so it cannot be enabled.
    #[error("nym label `{label}` has no gaze class and cannot be enabled")]
    UnsupportedLabel {
        /// The rejected label.
        label: String,
    },
    /// The label appears twice in the allowlist.
    #[error("nym label `{label}` is listed more than once")]
    DuplicateLabel {
        /// The repeated label.
        label: String,
    },
    /// An enabled label has no threshold.
    #[error("nym label `{label}` is enabled without a threshold")]
    MissingThreshold {
        /// The label without a threshold.
        label: String,
    },
    /// A threshold names a label that is not enabled.
    #[error("nym threshold for `{label}` names a label that is not in `labels`")]
    ThresholdWithoutLabel {
        /// The label with a stray threshold.
        label: String,
    },
    /// A threshold is not a finite number in `(0, 1]`.
    #[error("nym threshold for `{label}` must be in (0, 1], got {threshold}")]
    ThresholdOutOfRange {
        /// The label.
        label: String,
        /// The rejected threshold.
        threshold: f32,
    },
    /// No label is enabled, so the net could never report anything.
    #[error("nym allowlist is empty")]
    EmptyAllowlist,
}

/// Which Nym labels may produce a suspect, and the entity probability each one needs.
///
/// The allowlist is the key set. Built only through validating constructors, so every key maps to
/// a Gaze class and every threshold is in `(0, 1]`.
#[derive(Debug, Clone, PartialEq)]
pub struct NymOperatingPoint {
    thresholds: BTreeMap<NymLabel, f32>,
}

impl NymOperatingPoint {
    /// Op-B with personal-name support: the original building number, licence plate and username
    /// thresholds stay at 0.5 and date of birth stays at 0.9. GIVEN_NAME and SURNAME use 0.5,
    /// selected on an offline synthetic sweep, independently of the benchmark test corpus.
    /// Tax ID and ZIP code stay off (tax ID precision was 0.23 to 0.26; ZIP code flagged decoys).
    pub fn op_b() -> Self {
        Self {
            thresholds: BTreeMap::from([
                (NymLabel::BuildingNumber, 0.5),
                (NymLabel::DateOfBirth, 0.9),
                (NymLabel::GivenName, 0.5),
                (NymLabel::LicensePlate, 0.5),
                (NymLabel::Surname, 0.5),
                (NymLabel::Username, 0.5),
            ]),
        }
    }

    /// Builds an operating point from typed labels.
    pub fn new(
        thresholds: impl IntoIterator<Item = (NymLabel, f32)>,
    ) -> Result<Self, NymConfigError> {
        let mut out = BTreeMap::new();
        for (label, threshold) in thresholds {
            nym_label_to_safety_net_class(label)?;
            if !(threshold.is_finite() && threshold > 0.0 && threshold <= 1.0) {
                return Err(NymConfigError::ThresholdOutOfRange {
                    label: label.as_str().to_string(),
                    threshold,
                });
            }
            if out.insert(label, threshold).is_some() {
                return Err(NymConfigError::DuplicateLabel {
                    label: label.as_str().to_string(),
                });
            }
        }
        if out.is_empty() {
            return Err(NymConfigError::EmptyAllowlist);
        }
        Ok(Self { thresholds: out })
    }

    /// Builds an operating point from the policy spelling: an allowlist plus a threshold map
    /// that must name exactly the allowlisted labels.
    pub fn from_labels_and_thresholds(
        labels: &[String],
        thresholds: &BTreeMap<String, f32>,
    ) -> Result<Self, NymConfigError> {
        let parse = |raw: &str| {
            NymLabel::parse(raw).ok_or_else(|| NymConfigError::UnknownLabel {
                label: raw.to_string(),
            })
        };
        for raw in thresholds.keys() {
            parse(raw)?;
            if !labels.contains(raw) {
                return Err(NymConfigError::ThresholdWithoutLabel { label: raw.clone() });
            }
        }
        let mut pairs = Vec::with_capacity(labels.len());
        for raw in labels {
            let label = parse(raw)?;
            nym_label_to_safety_net_class(label)?;
            let threshold = *thresholds
                .get(raw)
                .ok_or_else(|| NymConfigError::MissingThreshold { label: raw.clone() })?;
            pairs.push((label, threshold));
        }
        Self::new(pairs)
    }

    /// Threshold for `label`, or `None` when the label is not enabled.
    pub fn threshold(&self, label: NymLabel) -> Option<f32> {
        self.thresholds.get(&label).copied()
    }

    /// Enabled labels with their thresholds, in label order.
    pub fn iter(&self) -> impl Iterator<Item = (NymLabel, f32)> + '_ {
        self.thresholds
            .iter()
            .map(|(label, threshold)| (*label, *threshold))
    }
}

impl Default for NymOperatingPoint {
    fn default() -> Self {
        Self::op_b()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn personal_name_labels_can_be_configured_as_name() {
        let op = NymOperatingPoint::new([(NymLabel::GivenName, 0.7), (NymLabel::Surname, 0.9)])
            .expect("personal-name labels carry the Name class");
        assert_eq!(
            nym_label_to_pii_class(NymLabel::GivenName),
            Ok(PiiClass::Name)
        );
        assert_eq!(
            nym_label_to_pii_class(NymLabel::Surname),
            Ok(PiiClass::Name)
        );
        assert_eq!(op.threshold(NymLabel::GivenName), Some(0.7));
        assert_eq!(op.threshold(NymLabel::Surname), Some(0.9));
    }

    #[test]
    fn label_order_matches_the_pinned_id2label() {
        assert_eq!(NymLabel::ALL.len(), 40);
        let mut sorted = NymLabel::ALL.iter().map(|l| l.as_str()).collect::<Vec<_>>();
        sorted.sort_unstable();
        let actual = NymLabel::ALL.iter().map(|l| l.as_str()).collect::<Vec<_>>();
        assert_eq!(actual, sorted, "id2label lists the types alphabetically");
        for label in NymLabel::ALL {
            assert_eq!(NymLabel::parse(label.as_str()), Some(*label));
        }
        assert_eq!(NymLabel::parse("zip_code"), None);
    }

    #[test]
    fn class_map_is_closed_with_explicit_person_name_labels() {
        let mapped = NymLabel::ALL
            .iter()
            .filter_map(|label| {
                nym_label_to_pii_class(*label)
                    .ok()
                    .map(|class| (label.as_str(), class.to_canonical_str()))
            })
            .collect::<Vec<_>>();
        assert_eq!(
            mapped,
            vec![
                ("BUILDING_NUMBER", "custom:building_number".to_string()),
                ("DATE_OF_BIRTH", "custom:date".to_string()),
                ("GIVEN_NAME", "name".to_string()),
                ("LICENSE_PLATE", "custom:license_plate".to_string()),
                ("SURNAME", "name".to_string()),
                ("TAX_ID", "custom:tax_id".to_string()),
                ("USERNAME", "custom:username".to_string()),
                ("ZIP_CODE", "custom:postal_code".to_string()),
            ]
        );
        assert_eq!(
            nym_label_to_safety_net_class(NymLabel::City),
            Err(NymConfigError::UnsupportedLabel {
                label: "CITY".to_string()
            })
        );
    }

    #[test]
    fn op_b_is_the_default() {
        let op_b = NymOperatingPoint::default();
        assert_eq!(
            op_b.iter().collect::<Vec<_>>(),
            vec![
                (NymLabel::BuildingNumber, 0.5),
                (NymLabel::DateOfBirth, 0.9),
                (NymLabel::GivenName, 0.5),
                (NymLabel::LicensePlate, 0.5),
                (NymLabel::Surname, 0.5),
                (NymLabel::Username, 0.5),
            ]
        );
        assert_eq!(op_b.threshold(NymLabel::TaxId), None);
        assert_eq!(op_b.threshold(NymLabel::ZipCode), None);
        assert_eq!(op_b.threshold(NymLabel::GivenName), Some(0.5));
        assert_eq!(op_b.threshold(NymLabel::Surname), Some(0.5));
    }

    fn policy(
        labels: &[&str],
        thresholds: &[(&str, f32)],
    ) -> Result<NymOperatingPoint, NymConfigError> {
        NymOperatingPoint::from_labels_and_thresholds(
            &labels.iter().map(|l| l.to_string()).collect::<Vec<_>>(),
            &thresholds
                .iter()
                .map(|(l, t)| (l.to_string(), *t))
                .collect(),
        )
    }

    #[test]
    fn policy_spelling_validates_every_rule() {
        assert_eq!(
            policy(
                &["LICENSE_PLATE", "ZIP_CODE"],
                &[("LICENSE_PLATE", 0.5), ("ZIP_CODE", 0.7)]
            )
            .unwrap()
            .iter()
            .collect::<Vec<_>>(),
            vec![(NymLabel::LicensePlate, 0.5), (NymLabel::ZipCode, 0.7)]
        );
        assert!(matches!(
            policy(&["PLATE"], &[("PLATE", 0.5)]),
            Err(NymConfigError::UnknownLabel { .. })
        ));
        assert!(matches!(
            policy(&["CITY"], &[("CITY", 0.5)]),
            Err(NymConfigError::UnsupportedLabel { .. })
        ));
        assert!(matches!(
            policy(&["USERNAME"], &[]),
            Err(NymConfigError::MissingThreshold { .. })
        ));
        assert!(matches!(
            policy(&["USERNAME"], &[("USERNAME", 0.5), ("TAX_ID", 0.5)]),
            Err(NymConfigError::ThresholdWithoutLabel { .. })
        ));
        assert!(matches!(
            policy(&["USERNAME", "USERNAME"], &[("USERNAME", 0.5)]),
            Err(NymConfigError::DuplicateLabel { .. })
        ));
        for bad in [0.0, -0.1, 1.01, f32::NAN] {
            assert!(matches!(
                policy(&["USERNAME"], &[("USERNAME", bad)]),
                Err(NymConfigError::ThresholdOutOfRange { .. })
            ));
        }
        assert_eq!(policy(&[], &[]), Err(NymConfigError::EmptyAllowlist));
    }
}