smoh commited on
Commit
4b51830
·
verified ·
1 Parent(s): aedc4c6

Stage DataFog PII v0.1.0: epoch-2 checkpoint, frozen MCP bundle, provenance and licenses (part 2)

Browse files
reproduction/pii/core.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ LABELS = ["first_name", "last_name", "email", "phone_number", "street_address", "customer_id", "account_number"]
7
+ BIO = ["O"] + [f"{prefix}-{label}" for label in LABELS for prefix in ("B", "I")]
8
+ MODEL_REVISION = "6ea81172465e8b0ad3fddeed32b986cdcdcffcf0"
9
+
10
+
11
+ def byte_positions(text):
12
+ positions = [0]
13
+ for ch in text:
14
+ positions.append(positions[-1] + len(ch.encode("utf-8")))
15
+ return positions
16
+
17
+
18
+ def encode_windows(tokenizer, text, max_length, stride):
19
+ """Tokenize the complete input before slicing; never use tokenizer overflow.
20
+
21
+ tokenizers 0.23.2 early-exits before producing overflow, silently losing text.
22
+ DistilBERT uses one CLS and one SEP token; raw offsets remain original chars.
23
+ """
24
+ capacity = max_length - 2
25
+ if not 0 <= stride < capacity:
26
+ raise ValueError("invalid stride or max_length")
27
+ if tokenizer.cls_token_id is None or tokenizer.sep_token_id is None:
28
+ raise ValueError("expected DistilBERT CLS/SEP tokenizer")
29
+ raw = tokenizer(text, add_special_tokens=False, truncation=False, return_offsets_mapping=True, verbose=False)
30
+ ids, offsets = raw["input_ids"], raw["offset_mapping"]
31
+ windows = {"input_ids": [], "attention_mask": [], "offset_mapping": []}
32
+ start = 0
33
+ while True:
34
+ end = min(start + capacity, len(ids))
35
+ window = [tokenizer.cls_token_id] + ids[start:end] + [tokenizer.sep_token_id]
36
+ windows["input_ids"].append(window)
37
+ windows["attention_mask"].append([1] * len(window))
38
+ windows["offset_mapping"].append([(0, 0)] + offsets[start:end] + [(0, 0)])
39
+ if end == len(ids):
40
+ break
41
+ start += capacity - stride
42
+ return windows
43
+
44
+
45
+ def decode_window(logits, offsets, text, max_length, has_previous=False, has_next=False):
46
+ scores = np.asarray(logits, dtype=np.float64)
47
+ scores = np.exp(scores - scores.max(axis=-1, keepdims=True))
48
+ scores /= scores.sum(axis=-1, keepdims=True)
49
+ ids = scores.argmax(axis=-1)
50
+ bp = byte_positions(text)
51
+ findings = []
52
+ current = None
53
+ for i, (start, end) in enumerate(offsets):
54
+ value = int(ids[i])
55
+ if start == end or value == 0:
56
+ if current:
57
+ findings.append(current)
58
+ current = None
59
+ continue
60
+ label = LABELS[(value - 1) // 2]
61
+ score = float(scores[i, value])
62
+ if value % 2 == 1 or current is None or current["label"] != label:
63
+ if current:
64
+ findings.append(current)
65
+ current = {"label": label, "start": bp[start], "end": bp[end], "raw_score": score, "confidence": None}
66
+ else:
67
+ current["end"] = bp[end]
68
+ current["raw_score"] = min(current["raw_score"], score)
69
+ if current:
70
+ findings.append(current)
71
+ content = [(a, b) for a, b in offsets if a != b]
72
+ if len(offsets) == max_length and content:
73
+ if any(f["start"] == bp[content[0][0]] and f["end"] == bp[content[-1][1]] for f in findings):
74
+ raise ValueError("entity spans a full window; complete entity boundary is unsupported")
75
+ if content:
76
+ for f in findings:
77
+ f["_boundary_fragment"] = bool((has_previous and f["start"] == bp[content[0][0]]) or (has_next and f["end"] == bp[content[-1][1]]))
78
+ return findings
79
+
80
+
81
+ def resolve_findings(findings):
82
+ # Prefer an observed containing prediction over a clipped window-edge fragment.
83
+ # Do not discard an edge prediction without a same-label containing candidate.
84
+ findings = [f for f in findings if not (f.get("_boundary_fragment", False) and any(
85
+ g["label"] == f["label"] and g["start"] <= f["start"] and g["end"] >= f["end"]
86
+ and (g["start"] < f["start"] or g["end"] > f["end"]) for g in findings))]
87
+ accepted = []
88
+ for finding in sorted(findings, key=lambda f: (-f["raw_score"], f["start"], f["end"], f["label"])):
89
+ if not any(finding["start"] < f["end"] and f["start"] < finding["end"] for f in accepted):
90
+ accepted.append({k:v for k,v in finding.items() if not k.startswith("_")})
91
+ return sorted(accepted, key=lambda f: (f["start"], f["end"], f["label"]))
92
+
93
+
94
+ def result(text, findings, calibration=None, threshold=None):
95
+ findings = resolve_findings(findings)
96
+ if calibration and calibration.get("method") == "isotonic":
97
+ for f in findings:
98
+ f["confidence"] = float(np.interp(f["raw_score"], calibration["xs"], calibration["ys"]))
99
+ if threshold is not None:
100
+ findings = [f for f in findings if (f["confidence"] if f["confidence"] is not None else f["raw_score"]) >= threshold]
101
+ data = text.encode("utf-8")
102
+ parts, cursor = [], 0
103
+ for f in findings:
104
+ parts.extend([data[cursor:f["start"]], b"[REDACTED]"])
105
+ cursor = f["end"]
106
+ parts.append(data[cursor:])
107
+ return {"findings": findings, "contains_pii": bool(findings), "redacted": b"".join(parts).decode("utf-8")}
108
+
109
+
110
+ def load_jsonl(path):
111
+ with open(path) as handle:
112
+ return [json.loads(line) for line in handle if line.strip()]
113
+
114
+
115
+ class Predictor:
116
+ def __init__(self, bundle, device="cpu", backend="onnx"):
117
+ from transformers import AutoTokenizer, AutoModelForTokenClassification
118
+ self.bundle = Path(bundle)
119
+ self.config = json.loads((self.bundle / "config.json").read_text())
120
+ self.tokenizer = AutoTokenizer.from_pretrained(self.bundle, local_files_only=True)
121
+ self.backend, self.device = backend, device
122
+ self.calibration = json.loads((self.bundle / self.config["calibration_file"]).read_text()) if self.config.get("calibration_file") else None
123
+ if backend == "onnx":
124
+ import onnxruntime as ort
125
+ options = ort.SessionOptions()
126
+ options.intra_op_num_threads = 1
127
+ self.session = ort.InferenceSession(str(self.bundle / "model.onnx"), sess_options=options, providers=["CPUExecutionProvider"])
128
+ else:
129
+ self.model = AutoModelForTokenClassification.from_pretrained(self.bundle / "checkpoint", local_files_only=True, attn_implementation="eager").to(device).eval()
130
+
131
+ def predict(self, text, calibrated=True):
132
+ encoded = encode_windows(self.tokenizer, text, self.config["max_length"], self.config["stride"])
133
+ findings = []
134
+ for index, (ids, mask, offsets) in enumerate(zip(encoded["input_ids"], encoded["attention_mask"], encoded["offset_mapping"])):
135
+ if self.backend == "onnx":
136
+ logits = self.session.run(None, {"input_ids": np.array([ids], dtype=np.int64), "attention_mask": np.array([mask], dtype=np.int64)})[0][0]
137
+ else:
138
+ import torch
139
+ with torch.no_grad():
140
+ logits = self.model(input_ids=torch.tensor([ids], device=self.device), attention_mask=torch.tensor([mask], device=self.device)).logits[0].cpu().numpy()
141
+ findings.extend(decode_window(logits, offsets, text, self.config["max_length"], index > 0, index + 1 < len(encoded["input_ids"])))
142
+ calibration = self.calibration if calibrated else None
143
+ return result(text, findings, calibration, calibration.get("threshold") if calibration else None)
reproduction/pii/data.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned public-data preparation, Unicode offsets, and leakage-resistant grouping."""
2
+ import ast
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from collections import Counter
7
+
8
+ LABELS = ('first_name','last_name','email','phone_number','street_address','customer_id','account_number')
9
+ DATA_REVISION = 'b70ffaf5ff39e079776134c5bf4381f00a9fd1ed'
10
+ MODEL_REVISION = '6ea81172465e8b0ad3fddeed32b986cdcdcffcf0'
11
+
12
+ def digest(text):
13
+ return hashlib.sha256(text.encode('utf-8')).hexdigest()
14
+
15
+ def validate(row):
16
+ """Exclude invalid rows; safely parse observed literals without evaluating code."""
17
+ try:
18
+ spans = json.loads(row['spans'])
19
+ except (ValueError, TypeError):
20
+ try:
21
+ spans = ast.literal_eval(row['spans'])
22
+ except (ValueError, SyntaxError, TypeError):
23
+ return None, 'parse'
24
+ if not isinstance(row['text'], str) or not isinstance(spans, list):
25
+ return None, 'schema'
26
+ for s in spans:
27
+ if not isinstance(s, dict) or not all(k in s for k in ('start','end','label','text')):
28
+ return None, 'span_schema'
29
+ if type(s['start']) is not int or type(s['end']) is not int or not isinstance(s['label'], str):
30
+ return None, 'span_schema'
31
+ if not 0 <= s['start'] < s['end'] <= len(row['text']):
32
+ return None, 'bounds'
33
+ if row['text'][s['start']:s['end']] != s['text']:
34
+ return None, 'source_slice_mismatch'
35
+ spans.sort(key=lambda s:(s['start'],s['end'],s['label']))
36
+ if any(a['end'] > b['start'] for a,b in zip(spans,spans[1:])):
37
+ return None, 'duplicate_or_overlap'
38
+ return spans, None
39
+
40
+ def family_key(text, spans):
41
+ # All annotated labels, not only target labels, identify reusable contexts.
42
+ parts=[]; previous=0
43
+ for s in spans:
44
+ parts.extend((text[previous:s['start']], '<'+s['label']+'>')); previous=s['end']
45
+ parts.append(text[previous:])
46
+ return digest(re.sub(r'\s+', ' ', ''.join(parts)).strip().casefold())
47
+
48
+ def support(rows):
49
+ docs=Counter(); entities=Counter(); negatives=0
50
+ for row in rows:
51
+ labels=[s['label'] for s in row['spans']]
52
+ entities.update(labels); docs.update(set(labels)); negatives += not labels
53
+ return {'documents':len(rows),'negative_documents':negatives,'positive_documents_by_label':dict(docs),'entities_by_label':dict(entities)}
reproduction/pii/evaluation.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact typed-span evaluation and held-out pooled isotonic calibration.
2
+
3
+ Gold offsets are Unicode character indices; predicted offsets are UTF-8 bytes.
4
+ Predicted-span reliability excludes missed entities; recall is reported separately.
5
+ """
6
+ import bisect
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+
11
+ LABELS = ('first_name', 'last_name', 'email', 'phone_number', 'street_address', 'customer_id', 'account_number')
12
+
13
+
14
+ def read_jsonl(path):
15
+ with open(path, encoding='utf-8') as f:
16
+ return [json.loads(line) for line in f if line.strip()]
17
+
18
+
19
+ def write_json(path, obj):
20
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
21
+ Path(path).write_text(json.dumps(obj, indent=2, allow_nan=False) + '\n')
22
+
23
+
24
+ def apply_calibration(score, calibration):
25
+ xs, ys = calibration['xs'], calibration['ys']
26
+ if score <= xs[0]:
27
+ return ys[0]
28
+ if score >= xs[-1]:
29
+ return ys[-1]
30
+ i = bisect.bisect_right(xs, score)
31
+ return ys[i-1] + (score-xs[i-1])/(xs[i]-xs[i-1])*(ys[i]-ys[i-1])
32
+
33
+
34
+ def _index(records):
35
+ out = {}
36
+ for r in records:
37
+ if r['id'] in out:
38
+ raise ValueError(f"Duplicate document ID: {r['id']}")
39
+ out[r['id']] = r
40
+ return out
41
+
42
+
43
+ def aligned(gold_records, prediction_records, calibration=None):
44
+ gold, predictions = _index(gold_records), _index(prediction_records)
45
+ if gold.keys() != predictions.keys():
46
+ raise ValueError('Gold/prediction document IDs differ; missing predictions cannot be silently omitted')
47
+ for doc_id, g in gold.items():
48
+ p = predictions[doc_id]
49
+ if g['text'] != p['text']:
50
+ raise ValueError(f'Text mismatch: {doc_id}')
51
+ offsets = [0]
52
+ for ch in g['text']:
53
+ offsets.append(offsets[-1] + len(ch.encode('utf-8')))
54
+ boundaries = set(offsets)
55
+ target = set()
56
+ for span in g['spans']:
57
+ a, b, label = span['start'], span['end'], span['label']
58
+ if label not in LABELS or not (isinstance(a, int) and isinstance(b, int) and 0 <= a < b < len(offsets)):
59
+ raise ValueError(f'Invalid gold span: {doc_id}: {span}')
60
+ target.add((label, offsets[a], offsets[b]))
61
+ found = {}
62
+ for span in p['findings']:
63
+ label, a, b = span['label'], span['start'], span['end']
64
+ raw = float(span['raw_score'])
65
+ if label not in LABELS or a not in boundaries or b not in boundaries or a >= b or not 0 <= raw <= 1:
66
+ raise ValueError(f'Invalid prediction: {doc_id}: {span}')
67
+ score = apply_calibration(raw, calibration) if calibration else span.get('confidence')
68
+ if score is None:
69
+ score = raw
70
+ score = float(score)
71
+ if not 0 <= score <= 1:
72
+ raise ValueError(f'Invalid confidence: {score}')
73
+ key = (label, a, b)
74
+ if key in found:
75
+ raise ValueError(f'Duplicate predicted span: {doc_id}: {key}')
76
+ found[key] = (raw, score)
77
+ yield doc_id, target, found
78
+
79
+
80
+ def _prf(tp, predicted, gold):
81
+ precision = tp / predicted if predicted else 0.0
82
+ recall = tp / gold if gold else 0.0
83
+ return {'tp': tp, 'fp': predicted-tp, 'fn': gold-tp, 'predicted': predicted, 'support': gold,
84
+ 'precision': precision, 'recall': recall, 'f1': 2*tp/(predicted+gold) if predicted+gold else 0.0}
85
+
86
+
87
+ def _merge(spans):
88
+ merged = []
89
+ for a, b in sorted((s[1], s[2]) for s in spans):
90
+ if merged and a <= merged[-1][1]:
91
+ merged[-1][1] = max(b, merged[-1][1])
92
+ else:
93
+ merged.append([a, b])
94
+ return merged
95
+
96
+
97
+ def _coverage(gold, pred):
98
+ g, p = _merge(gold), _merge(pred)
99
+ i = j = overlap = 0
100
+ while i < len(g) and j < len(p):
101
+ overlap += max(0, min(g[i][1], p[j][1])-max(g[i][0], p[j][0]))
102
+ if g[i][1] <= p[j][1]:
103
+ i += 1
104
+ else:
105
+ j += 1
106
+ return overlap, sum(b-a for a,b in p), sum(b-a for a,b in g)
107
+
108
+
109
+ def reliability(rows, score_index):
110
+ bins = [{'lower': i/10, 'upper': (i+1)/10, 'support': 0, 'mean_score': 0.0, 'accuracy': 0.0} for i in range(10)]
111
+ squared = 0.0
112
+ for row in rows:
113
+ score, correct = row[score_index], row[2]
114
+ b = bins[min(9, int(score*10))]
115
+ b['support'] += 1
116
+ b['mean_score'] += score
117
+ b['accuracy'] += correct
118
+ squared += (score-correct)**2
119
+ ece = 0.0
120
+ for b in bins:
121
+ if b['support']:
122
+ b['mean_score'] /= b['support']
123
+ b['accuracy'] /= b['support']
124
+ ece += b['support'] * abs(b['mean_score']-b['accuracy'])
125
+ else:
126
+ b['mean_score'] = b['accuracy'] = None
127
+ n = len(rows)
128
+ return {'support': n, 'brier': squared/n if n else None, 'ece': ece/n if n else None,
129
+ 'binning': '10 equal-width bins; left-inclusive, right-exclusive except final bin includes 1', 'bins': bins}
130
+
131
+
132
+ def evaluate(gold_records, prediction_records, threshold=None, calibration=None):
133
+ if threshold is None and calibration:
134
+ threshold = calibration['threshold']
135
+ counts = {label: [0, 0, 0] for label in LABELS}
136
+ coverage = [0, 0, 0]
137
+ docs = dict(total=0, positive_support=0, clean_support=0, false_positive=0, false_negative=0,
138
+ with_any_missed_entity=0, with_any_spurious_entity=0)
139
+ rows = []
140
+ for _, target, found in aligned(gold_records, prediction_records, calibration):
141
+ accepted = {k for k, (_, s) in found.items() if threshold is None or s >= threshold}
142
+ for k, (raw, score) in found.items():
143
+ rows.append((raw, score, int(k in target)))
144
+ for label in LABELS:
145
+ gs, ps = {s for s in target if s[0] == label}, {s for s in accepted if s[0] == label}
146
+ for i, value in enumerate((len(gs & ps), len(ps), len(gs))):
147
+ counts[label][i] += value
148
+ for i, value in enumerate(_coverage(target, accepted)):
149
+ coverage[i] += value
150
+ docs['total'] += 1
151
+ docs['positive_support'] += bool(target)
152
+ docs['clean_support'] += not target
153
+ docs['false_positive'] += bool(accepted) and not target
154
+ docs['false_negative'] += bool(target) and not accepted
155
+ docs['with_any_missed_entity'] += bool(target-accepted)
156
+ docs['with_any_spurious_entity'] += bool(accepted-target)
157
+ return {'threshold': threshold, 'overall': _prf(*(sum(c[i] for c in counts.values()) for i in range(3))),
158
+ 'per_label': {label: _prf(*c) for label, c in counts.items()},
159
+ 'redaction_byte_coverage': _prf(*coverage), 'documents': docs,
160
+ 'raw_reliability': reliability(rows, 0), 'confidence_reliability': reliability(rows, 1),
161
+ 'reliability_population': 'All predicted spans before threshold, correctness = exact typed span. Missed gold entities excluded; see recall.',
162
+ 'confidence_semantics': 'Pooled calibrated exact-span score' if calibration else 'Provided confidence when non-null; otherwise uncalibrated raw span score',
163
+ 'coverage_semantics': 'Union of UTF-8 bytes; ignores labels. Zero-denominator P/R/F1 reported as 0.'}
164
+
165
+
166
+ def fit_calibration(fit_gold, fit_predictions, select_gold, select_predictions):
167
+ fit_gold, select_gold = list(fit_gold), list(select_gold)
168
+ if {r['id'] for r in fit_gold} & {r['id'] for r in select_gold}:
169
+ raise ValueError('Calibration fit/select document IDs overlap')
170
+ hashes = lambda records: {hashlib.sha256(r['text'].encode()).hexdigest() for r in records}
171
+ if hashes(fit_gold) & hashes(select_gold):
172
+ raise ValueError('Calibration fit/select texts overlap')
173
+ fit_rows = [(raw, int(k in target)) for _, target, found in aligned(fit_gold, fit_predictions)
174
+ for k, (raw, _) in found.items()]
175
+ if not fit_rows:
176
+ raise ValueError('No predicted spans available to fit calibration')
177
+ from sklearn.isotonic import IsotonicRegression
178
+ model = IsotonicRegression(out_of_bounds='clip').fit([r[0] for r in fit_rows], [r[1] for r in fit_rows])
179
+ artifact = {'version': 1, 'method': 'isotonic', 'xs': model.X_thresholds_.tolist(), 'ys': model.y_thresholds_.tolist(),
180
+ 'interpolation': 'linear; clip outside fitted x range', 'score_semantics': 'Estimated exact typed-span correctness for predicted spans; pooled labels; raw score = minimum predicted-class token softmax',
181
+ 'experimental': True, 'fit_documents': len(fit_gold), 'fit_predicted_spans': len(fit_rows),
182
+ 'fit_correct_spans': sum(r[1] for r in fit_rows), 'select_documents': len(select_gold),
183
+ 'warnings': []}
184
+ if len({r[1] for r in fit_rows}) < 2:
185
+ artifact['warnings'].append('Fit targets contain only one outcome; constant map cannot demonstrate probability reliability.')
186
+ select_rows, support = [], 0
187
+ for _, target, found in aligned(select_gold, select_predictions, artifact):
188
+ support += len(target)
189
+ select_rows.extend((score, int(k in target)) for k, (_, score) in found.items())
190
+ if not select_rows or not support:
191
+ raise ValueError('Threshold selection requires predicted spans and gold entities')
192
+ select_rows.sort(reverse=True)
193
+ curve = []
194
+ tp = predicted = 0
195
+ i = 0
196
+ while i < len(select_rows):
197
+ threshold = select_rows[i][0]
198
+ while i < len(select_rows) and select_rows[i][0] == threshold:
199
+ tp += select_rows[i][1]
200
+ predicted += 1
201
+ i += 1
202
+ curve.append({'threshold': threshold, **_prf(tp, predicted, support)})
203
+ best = max(curve, key=lambda row: (row['f1'], row['recall'], row['threshold']))
204
+ artifact.update(threshold=best['threshold'], threshold_objective='Max exact typed-span F1 on calibration-select; ties favor recall, then higher threshold',
205
+ select_operating_point=best, threshold_curve=curve)
206
+ return artifact
reproduction/pyproject.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "datafog-pii"
3
+ version = "0.0.0"
4
+ description = "Local environment for the DataFog PII prototype"
5
+ requires-python = ">=3.12,<3.13"
6
+ dependencies = [
7
+ "torch",
8
+ "transformers",
9
+ "datasets",
10
+ "accelerate",
11
+ "safetensors",
12
+ "scikit-learn",
13
+ "onnx",
14
+ "onnxscript",
15
+ "onnxruntime",
16
+ ]
17
+
18
+ [tool.uv]
19
+ package = false
reproduction/scripts/benchmark_native.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Observe native CPU load and end-to-end request costs at actual input lengths."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ import psutil
10
+
11
+ def main():
12
+ root=Path(__file__).resolve().parents[1]
13
+ bundle=root/"artifacts/bundle"
14
+ binary=root/"rust/target/release/datafog-pii"
15
+ records=[json.loads(line) for line in (root/"data/prepared/validation.jsonl").read_text().splitlines()]
16
+ records.sort(key=lambda r:len(r["text"].encode()))
17
+ # Observed minimum, median, p90 and maximum validation lengths, not limits.
18
+ chosen=[records[i] for i in sorted({0,len(records)//2,int((len(records)-1)*0.9),len(records)-1})]
19
+ start=time.perf_counter()
20
+ process=subprocess.Popen([str(binary),str(bundle)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,bufsize=1)
21
+ # Empty request includes runtime initialization plus minimal inference: upper bound on load.
22
+ process.stdin.write(json.dumps({"text":""})+"\n");process.stdin.flush()
23
+ first=json.loads(process.stdout.readline())
24
+ if "error" in first:raise RuntimeError(first)
25
+ cold=time.perf_counter()-start
26
+ monitor=psutil.Process(process.pid);peak=monitor.memory_info().rss
27
+ observations=[]
28
+ for row in chosen:
29
+ start=time.perf_counter()
30
+ process.stdin.write(json.dumps({"text":row["text"]})+"\n");process.stdin.flush()
31
+ answer=json.loads(process.stdout.readline())
32
+ elapsed=time.perf_counter()-start
33
+ if "error" in answer:raise RuntimeError(answer)
34
+ peak=max(peak,monitor.memory_info().rss)
35
+ observations.append({"id":row["id"],"bytes":len(row["text"].encode()),"latency_seconds":elapsed,"findings":len(answer["findings"])})
36
+ process.stdin.close();process.wait()
37
+ if process.returncode:raise RuntimeError(process.stderr.read())
38
+ shutil.copy2(binary,bundle/"datafog-pii")
39
+ sums={}
40
+ for path in sorted(bundle.rglob("*")):
41
+ if path.is_file() and path.name!="checksums.json":
42
+ sums[str(path.relative_to(bundle))]=hashlib.sha256(path.read_bytes()).hexdigest()
43
+ (bundle/"checksums.json").write_text(json.dumps(sums,indent=2)+"\n")
44
+ evidence={"backend":"native Rust + ONNX Runtime CPU, one inference thread","bundle_bytes":sum(p.stat().st_size for p in bundle.rglob("*") if p.is_file()),"cold_load_seconds":cold,"cold_load_definition":"process launch through first empty-input response; includes initialization and minimal inference","warm_requests":observations,"peak_rss_bytes":peak,"memory_definition":"maximum RSS sampled after responses, not allocator high-water mark","warm_observed_requests_per_second":len(observations)/sum(r["latency_seconds"] for r in observations),"sampling":"one request each at observed min/median/p90/max validation byte lengths; descriptive observations, no latency guarantee"}
45
+ (root/"artifacts/native-benchmark.json").write_text(json.dumps(evidence,indent=2)+"\n")
46
+ print(json.dumps(evidence,indent=2))
47
+
48
+ if __name__=="__main__":main()
reproduction/scripts/calibrate.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+ from pii.evaluation import fit_calibration, read_jsonl, write_json
7
+
8
+ if __name__ == '__main__':
9
+ p = argparse.ArgumentParser(description='Fit pooled isotonic calibration and independent experimental F1 threshold')
10
+ for name in ('fit-gold', 'fit-predictions', 'select-gold', 'select-predictions', 'output'):
11
+ p.add_argument('--' + name, required=True)
12
+ args = p.parse_args()
13
+ artifact = fit_calibration(*(read_jsonl(getattr(args, key)) for key in ('fit_gold', 'fit_predictions', 'select_gold', 'select_predictions')))
14
+ write_json(args.output, artifact)
15
+ print(f"Saved calibration: {args.output}; experimental threshold={artifact['threshold']}")
reproduction/scripts/check_chunk_boundaries.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Oracle BIO logits prove window mechanics independently of trained predictions."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ import numpy as np
6
+ from transformers import AutoTokenizer
7
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
8
+ from pii.core import encode_windows, LABELS, byte_positions, decode_window, result
9
+
10
+ root=Path(__file__).resolve().parents[1]
11
+ tokenizer=AutoTokenizer.from_pretrained(root/'artifacts/backbone',local_files_only=True)
12
+ max_length,stride=512,42
13
+ cases=[]
14
+
15
+ def make_case(name,prefix,entities,suffix):
16
+ text=prefix; spans=[]
17
+ for label,value in entities:
18
+ start=len(text);text+=value;spans.append({'label':label,'start':start,'end':len(text)});text+=' '
19
+ text+=suffix
20
+ bp=byte_positions(text)
21
+ expected=[{'label':s['label'],'start':bp[s['start']],'end':bp[s['end']]} for s in spans]
22
+ encoded=encode_windows(tokenizer,text,max_length,stride)
23
+ raw=tokenizer(text,add_special_tokens=False,truncation=False,return_offsets_mapping=True,verbose=False)
24
+ covered=[]
25
+ for wi,ids in enumerate(encoded['input_ids']):covered.extend(ids[1 if wi==0 else stride+1:-1])
26
+ assert covered==raw['input_ids'], 'manual windows lost or duplicated tokens outside overlap'
27
+ windows=[]; findings=[]; covers=[0]*len(spans); clips=0
28
+ for wi,offsets in enumerate(encoded['offset_mapping']):
29
+ content=[(a,b) for a,b in offsets if a!=b]
30
+ complete=[content[0][0]<=s['start'] and content[-1][1]>=s['end'] for s in spans]
31
+ covers[:]=[v+int(c) for v,c in zip(covers,complete)]
32
+ logits=np.full((len(offsets),15),-8,dtype=np.float32)
33
+ for i,(a,b) in enumerate(offsets):
34
+ label_id=0; peak=12.
35
+ if a!=b:
36
+ for si,s in enumerate(spans):
37
+ if a<s['end'] and s['start']<b:
38
+ assert s['start']<=a and b<=s['end'], 'fixture boundary unrepresentable'
39
+ label_id=1+2*LABELS.index(s['label'])+int(a!=s['start'])
40
+ peak=5. if complete[si] else 12.
41
+ break
42
+ logits[i,label_id]=peak
43
+ decoded=decode_window(logits,offsets,text,max_length,wi>0,wi+1<len(encoded['input_ids']))
44
+ clips+=sum(f.get('_boundary_fragment',False) for f in decoded)
45
+ findings.extend(decoded)
46
+ windows.append({'input_ids':encoded['input_ids'][wi],'offsets':[[bp[a],bp[b]] for a,b in offsets], 'logits':logits.tolist()})
47
+ actual=result(text,findings)
48
+ assert [{k:f[k] for k in ('label','start','end')} for f in actual['findings']]==expected,(name,actual,expected)
49
+ assert all(covers), 'no complete window for entity'
50
+ # Demonstrate the old greedy rule really loses exact spans on boundary cases.
51
+ old=result(text,[{k:v for k,v in f.items() if k!='_boundary_fragment'} for f in findings])
52
+ old_wrong=[{k:f[k] for k in ('label','start','end')} for f in old['findings']]!=expected
53
+ if name!='adjacent-repeated-unicode':assert clips and old_wrong
54
+ cases.append({'name':name,'text':text,'expected':expected,'redacted':actual['redacted'],'windows':windows,'complete_window_counts':covers,'edge_predictions':clips,'old_greedy_would_fail':old_wrong,'full_token_coverage':True,'raw_tokens':len(raw['input_ids'])})
55
+
56
+ make_case('boundary-email','🙂 '+'hello '*505,[('email','jose.garcia@example.com')],'end '*30)
57
+ make_case('max43-token-boundary-entity','🙂 '+'hello '*490,[('street_address',' '.join(['alpha']*43))],'end '*30)
58
+ make_case('adjacent-repeated-unicode','🙂 ',[('first_name','José'),('last_name','García'),('first_name','José'),('last_name','García')],'done')
59
+ fixture={'max_length':max_length,'stride':stride,'labels':LABELS,'cases':cases}
60
+ (root/'rust/tests/boundary-oracle.json').write_text(json.dumps(fixture,separators=(',',':'))+'\n')
61
+ report={'max_length':max_length,'stride':stride,'policy':'Artificial-edge fragments suppressed only when a strictly containing same-label prediction exists. Remaining candidates use original greedy score ordering.', 'proof':'Oracle true BIO labels; partial-window probabilities intentionally exceed complete-window probabilities.', 'cases':[{k:v for k,v in c.items() if k not in ('text','windows','redacted')} for c in cases]}
62
+ (root/'artifacts/boundary-proof.json').write_text(json.dumps(report,indent=2)+'\n')
63
+ print(json.dumps(report,indent=2))
reproduction/scripts/check_environment.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small environment smoke check; does not download data or train a PII model."""
2
+ import importlib.metadata
3
+ import json
4
+ import platform
5
+ from pathlib import Path
6
+
7
+ import onnxruntime as ort
8
+ import torch
9
+
10
+ result = {
11
+ "platform": platform.platform(),
12
+ "python": platform.python_version(),
13
+ "versions": {name: importlib.metadata.version(name) for name in (
14
+ "torch", "transformers", "datasets", "accelerate", "safetensors",
15
+ "scikit-learn", "onnx", "onnxscript", "onnxruntime",
16
+ )},
17
+ "mps_built": torch.backends.mps.is_built(),
18
+ "mps_available": torch.backends.mps.is_available(),
19
+ "onnxruntime_providers": ort.get_available_providers(),
20
+ }
21
+ if result["mps_available"]:
22
+ torch.manual_seed(0)
23
+ model = torch.nn.Linear(8, 2).to("mps")
24
+ optimizer = torch.optim.AdamW(model.parameters())
25
+ before = model.weight.detach().clone()
26
+ loss = model(torch.ones(4, 8, device="mps")).square().mean()
27
+ loss.backward()
28
+ finite_gradients = all(torch.isfinite(p.grad).all().item() for p in model.parameters())
29
+ optimizer.step()
30
+ torch.mps.synchronize()
31
+ result["mps_smoke_check"] = {
32
+ "finite_loss": torch.isfinite(loss).item(),
33
+ "finite_gradients": finite_gradients,
34
+ "weights_changed": not torch.equal(before, model.weight),
35
+ }
36
+ assert all(result["mps_smoke_check"].values()), result
37
+ out = Path(__file__).resolve().parents[1] / "artifacts" / "environment-check.json"
38
+ out.parent.mkdir(exist_ok=True)
39
+ out.write_text(json.dumps(result, indent=2) + "\n")
40
+ print(json.dumps(result, indent=2))
reproduction/scripts/check_rust_parity.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check native Rust IDs, masks, offsets, logits, decoder/redaction and explicit errors."""
2
+ import argparse
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ import numpy as np
9
+ import onnxruntime as ort
10
+ from transformers import AutoTokenizer
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+ from pii.core import encode_windows, byte_positions, decode_window, result, Predictor
13
+
14
+ def main():
15
+ p=argparse.ArgumentParser()
16
+ p.add_argument('bundle',type=Path)
17
+ p.add_argument('--binary',type=Path,default=Path('rust/target/release/datafog-pii'))
18
+ p.add_argument('--output',type=Path)
19
+ args=p.parse_args()
20
+ config=json.loads((args.bundle/'config.json').read_text())
21
+ tokenizer=AutoTokenizer.from_pretrained(args.bundle,local_files_only=True)
22
+ opts=ort.SessionOptions(); opts.intra_op_num_threads=1
23
+ session=ort.InferenceSession(str(args.bundle/'model.onnx'),sess_options=opts,providers=['CPUExecutionProvider'])
24
+ predictor=Predictor(args.bundle)
25
+ calibration=json.loads((args.bundle/config['calibration_file']).read_text()) if config.get('calibration_file') else None
26
+ texts=['','Alice Smith','🙂 José García: jose@example.com\nAccount: ABC123','Email marketing is useful.','Ann Ann, phone 415-555-0123.','Customer message. '*550+'\nJosé García <jose@example.com>','Order delay. '*170+'é Smith\n'+'More detail. '*180]
27
+ payload=''.join(json.dumps({'text':text})+'\n' for text in texts)+'{"wrong":1}\n'+json.dumps({'text':'Recovery after invalid input'})+'\n'
28
+ start=time.perf_counter()
29
+ proc=subprocess.run([str(args.binary.resolve()),str(args.bundle.resolve()),'--debug'],input=payload,text=True,capture_output=True,check=True)
30
+ elapsed=time.perf_counter()-start
31
+ outputs=[json.loads(line) for line in proc.stdout.splitlines()]
32
+ assert len(outputs)==len(texts)+2
33
+ assert 'error' in outputs[-2] and 'contains_pii' not in outputs[-2],outputs[-2]
34
+ assert 'error' not in outputs[-1],outputs[-1]
35
+ max_diff=0.; checks=[]
36
+ for text,out in zip(texts,outputs):
37
+ assert 'error' not in out,out
38
+ encoded=encode_windows(tokenizer,text,config['max_length'],config['stride'])
39
+ assert len(encoded['input_ids'])==len(out['windows'])
40
+ findings=[]
41
+ for i,window in enumerate(out.pop('windows')):
42
+ assert window['input_ids']==encoded['input_ids'][i]
43
+ assert window['attention_mask']==encoded['attention_mask'][i]
44
+ bp=byte_positions(text)
45
+ assert window['offsets']==[[bp[a],bp[b]] for a,b in encoded['offset_mapping'][i]]
46
+ logits=session.run(None,{k:np.array([encoded[k][i]],dtype=np.int64) for k in ('input_ids','attention_mask')})[0][0]
47
+ rust_logits=np.array(window['logits'],dtype=np.float32)
48
+ diff=float(np.abs(logits-rust_logits).max()); max_diff=max(max_diff,diff)
49
+ # Same CPU runtime and one-thread settings: require exact f32 values.
50
+ np.testing.assert_array_equal(logits,rust_logits)
51
+ findings.extend(decode_window(logits,encoded['offset_mapping'][i],text,config['max_length'], i > 0, i + 1 < len(encoded['input_ids'])))
52
+ expected=result(text,findings,calibration,calibration.get('threshold') if calibration else None)
53
+ public=predictor.predict(text)
54
+ assert public==expected, 'actual Predictor differs from shared reference'
55
+ assert out['contains_pii']==expected['contains_pii']
56
+ assert out['redacted']==expected['redacted']
57
+ assert len(out['findings'])==len(expected['findings'])
58
+ for actual,want in zip(out['findings'],expected['findings']):
59
+ for key in ('start','end','label'):assert actual[key]==want[key]
60
+ # f64 scalar summation vs numpy reduction differ at rounding scale.
61
+ assert abs(actual['raw_score']-want['raw_score'])<=64*np.finfo(np.float64).eps
62
+ if want['confidence'] is None: assert actual['confidence'] is None
63
+ else: assert abs(actual['confidence']-want['confidence'])<=1e-10
64
+ checks.append({'bytes':len(text.encode()),'windows':len(encoded['input_ids']),'findings':len(out['findings'])})
65
+ report={'bundle':str(args.bundle),'cases':checks,'max_abs_logit_difference':max_diff,'logits_bit_identical':True,'token_ids_masks_global_byte_offsets_parity':True,'findings_redaction_parity':True,'actual_python_predictor_parity':True,'malformed_request_explicit_error_and_recovery':True,'cold_process_debug_batch_seconds':elapsed,'note':'Parity fixtures prove implementation consistency, not detection quality.'}
66
+ serialized=json.dumps(report,indent=2)+'\n'
67
+ if args.output:args.output.write_text(serialized)
68
+ print(serialized)
69
+ if __name__=='__main__':main()
reproduction/scripts/check_training.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnostic learning and exact optimizer/RNG resume proof; not held-out quality."""
2
+ import sys,json,time,random,hashlib,gc
3
+ from pathlib import Path
4
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
5
+ sys.path.insert(0,str(Path(__file__).resolve().parent))
6
+ import numpy as np
7
+ import torch
8
+ from transformers import AutoTokenizer,AutoModelForTokenClassification,DataCollatorForTokenClassification
9
+ from pii.core import LABELS,BIO,decode_window,byte_positions
10
+ from train import prepare
11
+
12
+ out=Path('work/training-diagnostic');out.mkdir(parents=True,exist_ok=True)
13
+ random.seed(42);np.random.seed(42);torch.manual_seed(42)
14
+ tok=AutoTokenizer.from_pretrained('artifacts/backbone',local_files_only=True)
15
+ # Shortest complete documents providing every target label keeps diagnostic bounded.
16
+ rows=[json.loads(x) for x in open('data/prepared/train.jsonl')]
17
+ rows.sort(key=lambda r:(len(r['text']),r['id']))
18
+ selected=[];missing=set(LABELS)
19
+ for r in rows:
20
+ labels={s['label'] for s in r['spans']}
21
+ if labels & missing:selected.append(r);missing-=labels
22
+ if not missing:break
23
+ chunks,stats=prepare(selected,tok,512,42)
24
+ collate=DataCollatorForTokenClassification(tok,return_tensors='pt')
25
+ batch={k:v.to('mps') for k,v in collate(chunks).items() if k in ('input_ids','attention_mask','labels')}
26
+ model=AutoModelForTokenClassification.from_pretrained('artifacts/backbone',num_labels=len(BIO),id2label=dict(enumerate(BIO)),label2id={v:k for k,v in enumerate(BIO)},attn_implementation='eager',local_files_only=True).to('mps')
27
+ opt=torch.optim.AdamW(model.parameters(),lr=2e-5,weight_decay=.01)
28
+ offsets=[tok(r['text'],return_offsets_mapping=True)['offset_mapping'] for r in selected]
29
+ gold=[]
30
+ for r in selected:
31
+ bp=byte_positions(r['text']);gold.append({(s['label'],bp[s['start']],bp[s['end']]) for s in r['spans']})
32
+ def assess():
33
+ model.eval()
34
+ with torch.no_grad():o=model(**batch)
35
+ found=0;pred=0
36
+ for r,off,logits,g in zip(selected,offsets,o.logits.cpu().numpy(),gold):
37
+ p={(f['label'],f['start'],f['end']) for f in decode_window(logits[:len(off)],off,r['text'],512)}
38
+ found+=len(p&g);pred+=len(p)
39
+ return {'loss':o.loss.item(),'exact_entity_true_positives':found,'gold_entities':sum(map(len,gold)),'predicted_entities':pred,'recall':found/sum(map(len,gold))}
40
+ def step():
41
+ model.train();opt.zero_grad(set_to_none=True);loss=model(**batch).loss
42
+ assert torch.isfinite(loss);loss.backward();torch.nn.utils.clip_grad_norm_(model.parameters(),1.,error_if_nonfinite=True);opt.step();return loss.item()
43
+ def fingerprint():
44
+ h=hashlib.sha256()
45
+ for n,p in model.named_parameters():h.update(n.encode());h.update(p.detach().cpu().numpy().tobytes())
46
+ return h.hexdigest()
47
+ start=time.perf_counter();before=assess();history=[]
48
+ while True:
49
+ loss=step();a=assess();history.append(a)
50
+ if a['loss']<before['loss'] and a['recall']>before['recall']:break
51
+ if time.perf_counter()-start>180:raise RuntimeError('Diagnostic 3-minute coordination budget exhausted; no learning claim')
52
+ model.save_pretrained(out/'model')
53
+ state={'optimizer':opt.state_dict(),'random':random.getstate(),'numpy':np.random.get_state(),'torch':torch.get_rng_state(),'mps':torch.mps.get_rng_state()}
54
+ torch.save(state,out/'state.pt');del state
55
+ uninterrupted_loss=step();uninterrupted_hash=fingerprint();uninterrupted=assess();torch.save({k:v.detach().cpu() for k,v in model.state_dict().items()},out/'uninterrupted.pt')
56
+ del model,opt;gc.collect();torch.mps.empty_cache()
57
+ model=AutoModelForTokenClassification.from_pretrained(out/'model',local_files_only=True,attn_implementation='eager').to('mps')
58
+ opt=torch.optim.AdamW(model.parameters(),lr=2e-5,weight_decay=.01)
59
+ state=torch.load(out/'state.pt',map_location='cpu',weights_only=False);opt.load_state_dict(state['optimizer'])
60
+ random.setstate(state['random']);np.random.set_state(state['numpy']);torch.set_rng_state(state['torch']);torch.mps.set_rng_state(state['mps']);del state
61
+ resumed_loss=step();resumed_hash=fingerprint();resumed=assess()
62
+ reference=torch.load(out/'uninterrupted.pt',weights_only=True)
63
+ max_parameter_difference=max((v.detach().cpu()-reference[k]).abs().max().item() for k,v in model.state_dict().items())
64
+ del reference
65
+ report={'purpose':'Training functionality diagnostic only; same examples fitted and scored, no benchmark','device':'mps','seed':42,'recipe':{'lr':2e-5,'weight_decay':.01,'gradient_clip':1.},'selected_ids':[r['id'] for r in selected],'labels_covered':LABELS,'chunk_stats':stats,'steps_to_first_learning_evidence':len(history),'stopping_rule':'First evaluation with both lower token loss and greater exact-entity recall than random-head baseline','before':before,'after':history[-1],'resume':{'checkpoint':'work/training-diagnostic','uninterrupted_next_update_loss':uninterrupted_loss,'resumed_next_update_loss':resumed_loss,'uninterrupted_parameters_sha256':uninterrupted_hash,'resumed_parameters_sha256':resumed_hash,'all_model_parameters_bitwise_equal':uninterrupted_hash==resumed_hash,'max_parameter_abs_difference':max_parameter_difference,'resume_status':'Same next-update loss and evaluation; MPS parameter differences reported without a bitwise guarantee','uninterrupted_metrics':uninterrupted,'resumed_metrics':resumed},'elapsed_seconds':time.perf_counter()-start}
66
+ Path('artifacts/training-check.json').write_text(json.dumps(report,indent=2));print(json.dumps(report,indent=2),flush=True)
67
+ assert uninterrupted_loss==resumed_loss and uninterrupted==resumed,'Resumed loss or outputs differ'
reproduction/scripts/evaluate.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+ from pii.evaluation import evaluate, read_jsonl, write_json
8
+
9
+ if __name__ == '__main__':
10
+ p = argparse.ArgumentParser(description='Evaluate complete saved span predictions against character-offset gold JSONL')
11
+ for name in ('gold', 'predictions', 'output'):
12
+ p.add_argument('--' + name, required=True)
13
+ p.add_argument('--calibration')
14
+ p.add_argument('--threshold', type=float)
15
+ args = p.parse_args()
16
+ calibration = json.loads(Path(args.calibration).read_text()) if args.calibration else None
17
+ result = evaluate(read_jsonl(args.gold), read_jsonl(args.predictions), args.threshold, calibration)
18
+ write_json(args.output, result)
19
+ print(json.dumps({'overall': result['overall'], 'documents': result['documents']}))
reproduction/scripts/export_model.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export selected local checkpoint into a native inference bundle."""
2
+ import hashlib
3
+ import json
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
8
+ import numpy as np
9
+ import onnxruntime as ort
10
+ import torch
11
+ from transformers import AutoModelForTokenClassification, AutoTokenizer
12
+ from pii.core import LABELS, MODEL_REVISION
13
+
14
+ class Export(torch.nn.Module):
15
+ def __init__(self,model):
16
+ super().__init__();self.model=model
17
+ def forward(self,input_ids,attention_mask):
18
+ return self.model(input_ids=input_ids,attention_mask=attention_mask).logits
19
+
20
+ def main():
21
+ out=Path("artifacts/bundle");out.mkdir(parents=True,exist_ok=True)
22
+ training=json.loads(Path("artifacts/training/run-config.json").read_text())
23
+ source=Path("artifacts/training/best")
24
+ tokenizer=AutoTokenizer.from_pretrained(source,local_files_only=True)
25
+ model=AutoModelForTokenClassification.from_pretrained(source,local_files_only=True,attn_implementation="eager").eval()
26
+ sample=tokenizer("🙂 José Smith: jose@example.com",return_tensors="pt")
27
+ tokenizer.save_pretrained(out)
28
+ config={"labels":LABELS,"max_length":training["max_length"],"stride":training["stride"],"model_revision":MODEL_REVISION,"dataset_revision":"b70ffaf5ff39e079776134c5bf4381f00a9fd1ed","offsets":"half-open UTF-8 bytes","raw_score":"minimum predicted-class token softmax within span","status":"experimental prototype, uncalibrated until calibration_file supplied"}
29
+ (out/"config.json").write_text(json.dumps(config,indent=2)+"\n")
30
+ wrapper=Export(model).eval()
31
+ torch.onnx.export(wrapper, (sample["input_ids"],sample["attention_mask"]),str(out/"model.onnx"),input_names=["input_ids","attention_mask"],output_names=["logits"],dynamic_axes={"input_ids":{0:"batch",1:"sequence"},"attention_mask":{0:"batch",1:"sequence"},"logits":{0:"batch",1:"sequence"}},opset_version=17,dynamo=False)
32
+ assert not model.training, "Export must preserve evaluation mode for parity checks"
33
+ dylibs=list((Path(ort.__file__).parent/"capi").glob("libonnxruntime.*.dylib"))
34
+ if len(dylibs)!=1:raise RuntimeError(f"Ambiguous native runtime paths: {dylibs}")
35
+ shutil.copy2(dylibs[0],out/"libonnxruntime.dylib")
36
+ shutil.copytree("rust/licenses",out/"licenses",dirs_exist_ok=True)
37
+ shutil.copy2("artifacts/backbone/README.md",out/"licenses/backbone-model-card.md")
38
+ (out/"licenses/dataset-attribution.txt").write_text("Nemotron-PII by NVIDIA, CC BY 4.0. Source: https://huggingface.co/datasets/nvidia/Nemotron-PII\nRevision b70ffaf5ff39e079776134c5bf4381f00a9fd1ed. Training filters and preparation modifications: see artifacts/data-manifest.json and docs/data.md.\n")
39
+ options=ort.SessionOptions();options.intra_op_num_threads=1
40
+ session=ort.InferenceSession(str(out/"model.onnx"),sess_options=options,providers=["CPUExecutionProvider"])
41
+ checks=[]
42
+ for texts in ([""],["Alice Smith", "🙂 José García: jose@example.com"],["Long ticket. "*170]):
43
+ x=tokenizer(texts,return_tensors="pt",padding=True,truncation=True,max_length=training["max_length"])
44
+ x={k:v for k,v in x.items() if k in ("input_ids","attention_mask")}
45
+ with torch.no_grad():expected=model(**x).logits.numpy()
46
+ actual=session.run(None,{k:v.numpy() for k,v in x.items()})[0]
47
+ checks.append({"batch":len(texts),"sequence":x["input_ids"].shape[1],"max_abs_difference":float(np.abs(expected-actual).max()),"argmax_identical":bool(np.array_equal(expected.argmax(-1),actual.argmax(-1)))})
48
+ if not checks[-1]["argmax_identical"]:raise RuntimeError("Export changed probe token predictions")
49
+ (out/"export-evidence.json").write_text(json.dumps(checks,indent=2)+"\n")
50
+ print(json.dumps(checks,indent=2),flush=True)
51
+
52
+ if __name__=="__main__":main()
reproduction/scripts/final_audit.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib,json,subprocess
2
+ from pathlib import Path
3
+ root=Path.cwd(); bundle=root/'artifacts/bundle'
4
+ def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
5
+ freeze=json.loads((root/'artifacts/pipeline/evaluation-freeze.json').read_text())['sha256']
6
+ assert all(sha(root/p)==v for p,v in freeze.items()),'freeze changed'
7
+ checks=json.loads((bundle/'checksums.json').read_text())
8
+ assert all(sha(bundle/p)==v for p,v in checks.items()),'bundle checksum mismatch'
9
+ cal=json.loads((bundle/'calibration.json').read_text())
10
+ import bisect
11
+ def confidence(x):
12
+ xs,ys=cal['xs'],cal['ys']
13
+ if x<=xs[0]:return ys[0]
14
+ if x>=xs[-1]:return ys[-1]
15
+ i=bisect.bisect_right(xs,x)
16
+ return ys[i-1]+(x-xs[i-1])*(ys[i]-ys[i-1])/(xs[i]-xs[i-1])
17
+ n=tp=fp=fn=0
18
+ with open('data/prepared/test.jsonl') as gold,open('artifacts/predictions/test.jsonl') as pred:
19
+ import itertools
20
+ for g,p in itertools.zip_longest(gold,pred):
21
+ assert g and p
22
+ g,p=json.loads(g),json.loads(p)
23
+ assert g['id']==p['id'] and g['text']==p['text']
24
+ text=g['text']; offsets=[0]
25
+ for c in text:offsets.append(offsets[-1]+len(c.encode()))
26
+ targets={(s['label'],offsets[s['start']],offsets[s['end']]) for s in g['spans']}
27
+ findings={(s['label'],s['start'],s['end']) for s in p['findings'] if confidence(s['raw_score'])>=cal['threshold']}
28
+ tp+=len(targets&findings);fp+=len(findings-targets);fn+=len(targets-findings);n+=1
29
+ expected=json.loads(Path('artifacts/evaluation/test.json').read_text())
30
+ assert [tp,fp,fn]==[expected['overall'][k] for k in ('tp','fp','fn')]
31
+ assert n==90018
32
+ proc=subprocess.run([str(bundle/'datafog-pii'),str(bundle)],input=json.dumps({'text':'🙂 Email me at alice@example.com.'})+'\n'+json.dumps({'text':'The screen is blank.'})+'\n',text=True,capture_output=True,check=True,env={'PATH':'/usr/bin:/bin'})
33
+ outputs=[json.loads(x) for x in proc.stdout.splitlines()]
34
+ assert len(outputs)==2 and all('error' not in x for x in outputs)
35
+ report={'frozen_inputs_unchanged':True,'bundle_checksums_verified':len(checks),'independent_metric_recount':{'documents':n,'tp':tp,'fp':fp,'fn':fn},'packaged_native_smoke_without_python_path':outputs}
36
+ Path('artifacts/final-audit.json').write_text(json.dumps(report,indent=2)+'\n')
37
+ print(json.dumps(report,indent=2))
reproduction/scripts/predict.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Save unfiltered predictions from the deployed ONNX model for evaluation."""
2
+ import argparse
3
+ from collections import Counter
4
+ import json
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+ from transformers import AutoTokenizer
12
+ from pii.core import decode_window, result, encode_windows
13
+
14
+ def main():
15
+ p=argparse.ArgumentParser()
16
+ p.add_argument("--bundle",type=Path,default=Path("artifacts/bundle"))
17
+ p.add_argument("--input",type=Path,required=True)
18
+ p.add_argument("--output",type=Path,required=True)
19
+ p.add_argument("--batch-size",type=int,default=16,help="Measured training recipe batch; dynamically padded ONNX batches")
20
+ args=p.parse_args()
21
+ config=json.loads((args.bundle/"config.json").read_text())
22
+ tokenizer=AutoTokenizer.from_pretrained(args.bundle,local_files_only=True)
23
+ options=ort.SessionOptions();options.intra_op_num_threads=1
24
+ session=ort.InferenceSession(str(args.bundle/"model.onnx"),sess_options=options,providers=["CPUExecutionProvider"])
25
+ args.output.parent.mkdir(parents=True,exist_ok=True)
26
+ started=time.perf_counter();documents=windows=0
27
+ def batch_predict(rows,handle):
28
+ nonlocal documents,windows
29
+ encoded={"input_ids":[],"attention_mask":[],"offset_mapping":[]}
30
+ mapping=[]
31
+ for i,row in enumerate(rows):
32
+ windows_for_row=encode_windows(tokenizer,row["text"],config["max_length"],config["stride"])
33
+ for key in encoded:encoded[key].extend(windows_for_row[key])
34
+ mapping.extend([i]*len(windows_for_row["input_ids"]))
35
+ counts=Counter(mapping);seen=Counter();findings=[[] for _ in rows]
36
+ for begin in range(0,len(mapping),args.batch_size):
37
+ end=min(len(mapping),begin+args.batch_size)
38
+ length=max(len(x) for x in encoded["input_ids"][begin:end])
39
+ ids=np.full((end-begin,length),tokenizer.pad_token_id,dtype=np.int64)
40
+ masks=np.zeros_like(ids)
41
+ for j,index in enumerate(range(begin,end)):
42
+ n=len(encoded["input_ids"][index]);ids[j,:n]=encoded["input_ids"][index];masks[j,:n]=encoded["attention_mask"][index]
43
+ logits=session.run(None,{"input_ids":ids,"attention_mask":masks})[0]
44
+ for j,index in enumerate(range(begin,end)):
45
+ sample=mapping[index];offsets=encoded["offset_mapping"][index]
46
+ findings[sample].extend(decode_window(logits[j,:len(offsets)],offsets,rows[sample]["text"],config["max_length"],has_previous=seen[sample]>0,has_next=seen[sample]+1<counts[sample]))
47
+ seen[sample]+=1
48
+ for row,found in zip(rows,findings):
49
+ prediction={"id":row["id"],"text":row["text"],**result(row["text"],found)}
50
+ handle.write(json.dumps(prediction,ensure_ascii=False)+"\n")
51
+ documents+=len(rows);windows+=len(mapping)
52
+ args.output.with_suffix(".progress.json").write_text(json.dumps({"documents":documents,"windows":windows,"seconds":time.perf_counter()-started})+"\n")
53
+ temporary=args.output.with_suffix(".partial.jsonl")
54
+ with args.input.open() as source,temporary.open("w") as target:
55
+ rows=[]
56
+ for line in source:
57
+ if not line.strip():continue
58
+ rows.append(json.loads(line))
59
+ if len(rows)==args.batch_size:batch_predict(rows,target);rows=[]
60
+ if rows:batch_predict(rows,target)
61
+ temporary.replace(args.output)
62
+ evidence={"documents":documents,"windows":windows,"seconds":time.perf_counter()-started,"backend":"ONNX Runtime CPU","batch_size":args.batch_size,"raw_unfiltered":True}
63
+ args.output.with_suffix(".evidence.json").write_text(json.dumps(evidence,indent=2)+"\n")
64
+ print(json.dumps(evidence),flush=True)
65
+
66
+ if __name__=="__main__":main()
reproduction/scripts/prepare_data.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run from project root: .venv/bin/python scripts/prepare_data.py."""
3
+ import sys
4
+ from pathlib import Path
5
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
6
+ import json, math, hashlib, bisect
7
+ from collections import Counter, defaultdict
8
+ import numpy as np
9
+ import pyarrow.parquet as pq
10
+ from huggingface_hub import hf_hub_download
11
+ from transformers import AutoTokenizer
12
+ from pii.data import LABELS, DATA_REVISION, MODEL_REVISION, validate, digest, family_key, support
13
+
14
+ ROOT=Path(__file__).resolve().parents[1]
15
+ RAW=ROOT/'data/raw'; OUT=ROOT/'data/prepared'; ART=ROOT/'artifacts'
16
+ OUT.mkdir(parents=True,exist_ok=True); ART.mkdir(exist_ok=True)
17
+ manifest={'source':'nvidia/Nemotron-PII','revision':DATA_REVISION,'license':'CC-BY-4.0','attribution':'NVIDIA; Amy Steier, Andre Manoel, Alexa Haushalter, Maarten Van Segbroeck, Nemotron-PII (2025). https://huggingface.co/datasets/nvidia/Nemotron-PII','source_files':{},'source_stats':{},'exclusions':{}}
18
+ files=['README.md']+[f'data/{s}-00000-of-00001.parquet' for s in ('train','test')]
19
+ for f in files:
20
+ p=Path(hf_hub_download(manifest['source'],f,repo_type='dataset',revision=DATA_REVISION,local_dir=RAW))
21
+ manifest['source_files'][f]={'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'bytes':p.stat().st_size}
22
+ rows=[]; excluded=[]; parent=[]; key_owner={}
23
+ def find(i):
24
+ while parent[i]!=i:
25
+ parent[i]=parent[parent[i]]; i=parent[i]
26
+ return i
27
+ def union(i,j):
28
+ a,b=find(i),find(j)
29
+ if a!=b: parent[max(a,b)]=min(a,b)
30
+ for source in ('train','test'):
31
+ table=pq.read_table(RAW/f'data/{source}-00000-of-00001.parquet')
32
+ stats={'rows':len(table),'labels':Counter(),'invalid':Counter(),'empty_all_spans':0,'semantic_flags':Counter()}
33
+ seen_uids=Counter(); seen_texts=Counter()
34
+ for n,r in enumerate(table.to_pylist()):
35
+ seen_uids[r['uid']]+=1; seen_texts[digest(r['text'])]+=1
36
+ spans,reason=validate(r)
37
+ if reason:
38
+ stats['invalid'][reason]+=1; excluded.append({'source':source,'index':n,'uid':r['uid'],'reason':reason}); continue
39
+ stats['labels'].update(s['label'] for s in spans); stats['empty_all_spans']+=not spans
40
+ for s in spans:
41
+ if s['label']=='email' and '@' not in s['text']:
42
+ stats['semantic_flags']['email_without_at']+=1
43
+ row={'id':f'{source}:{n}:{r["uid"]}','uid':r['uid'],'source':source,'source_index':n,'text':r['text'],'spans':[{k:s[k] for k in ('label','start','end')} for s in spans if s['label'] in LABELS], 'domain':r['domain'],'document_type':r['document_type'],'family':family_key(r['text'],spans),'text_sha256':digest(r['text'])}
44
+ i=len(rows); parent.append(i)
45
+ for key in ('uid:'+row['uid'],'text:'+row['text_sha256'],'family:'+row['family']):
46
+ if key in key_owner: union(i,key_owner[key])
47
+ else:key_owner[key]=i
48
+ rows.append(row)
49
+ stats['duplicate_uid_rows']=sum(v-1 for v in seen_uids.values()); stats['duplicate_exact_text_rows']=sum(v-1 for v in seen_texts.values())
50
+ manifest['source_stats'][source]=stats
51
+ print(source,json.dumps(stats),flush=True)
52
+ groups=defaultdict(list)
53
+ for i,row in enumerate(rows): groups[find(i)].append(row)
54
+ splits={s:[] for s in ('train','validation','calibration_fit','calibration_select','test')}
55
+ train_groups=[]; overlaps=0
56
+ for group in groups.values():
57
+ group_id=min(r['id'] for r in group)
58
+ for r in group:r['group']=digest(group_id)
59
+ test=[r for r in group if r['source']=='test']
60
+ if test:
61
+ splits['test'].extend(test)
62
+ for r in group:
63
+ if r['source']=='train':
64
+ excluded.append({'source':'train','index':r['source_index'],'uid':r['uid'],'reason':'test_family_overlap'}); overlaps+=1
65
+ else:train_groups.append(group)
66
+ # Stable pseudorandom family order. Allocate heldouts jointly, without model predictions.
67
+ train_groups.sort(key=lambda g:digest('datafog-pii-split-v1:'+g[0]['group']))
68
+ # Owner-approved transparent volume-derived heuristic; no quality guarantee.
69
+ all_support=Counter()
70
+ for group in train_groups:
71
+ for r in group: all_support.update(set(s['label'] for s in r['spans']) or ['__negative__'])
72
+ quota=math.ceil(math.sqrt(min(all_support[l] for l in LABELS)))
73
+ heldouts=('validation','calibration_fit','calibration_select'); counts={s:Counter() for s in heldouts}
74
+ for group in train_groups:
75
+ coverage=Counter()
76
+ for r in group:
77
+ labels=set(s['label'] for s in r['spans']); coverage.update(labels or ['__negative__'])
78
+ deficits={s:sum(min(v,max(0,quota-counts[s][l])) for l,v in coverage.items()) for s in heldouts}
79
+ chosen=max(heldouts,key=lambda s:(deficits[s],-len(splits[s])))
80
+ if deficits[chosen]==0:chosen='train'
81
+ splits[chosen].extend(group)
82
+ if chosen in counts:counts[chosen].update(coverage)
83
+ manifest['split_rule']={'quota_positive_documents_per_label_and_negative_documents':quota,'derivation':'ceil(sqrt(minimum observed eligible positive-document support across target labels)); equal support in three heldouts. Transparent prototype allocation heuristic balancing training retention with heldout growth; no statistical precision or quality guarantee.','eligible_train_document_support':dict(all_support),'grouping':'Connected components of UID, exact original text SHA256, and whitespace-collapsed casefolded all-label-masked text SHA256. All train groups touching test excluded. Source test retained in full except structurally invalid rows.','allocation':'Stable SHA256 family order; assign each family to heldout with greatest remaining document-support deficit (tie: smaller partition), otherwise train.','seed':'datafog-pii-split-v1','test_overlap_train_rows':overlaps,'heldout_achieved_support':counts}
84
+ manifest['splits']={}
85
+ for name,records in splits.items():
86
+ records.sort(key=lambda r:r['id'])
87
+ p=OUT/f'{name}.jsonl'
88
+ with p.open('w') as f:
89
+ for r in records:f.write(json.dumps(r,ensure_ascii=False)+'\n')
90
+ manifest['splits'][name]={**support(records),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}
91
+ print(name,json.dumps(manifest['splits'][name]),flush=True)
92
+ sets={name:set(r['group'] for r in records) for name,records in splits.items()}
93
+ for a in sets:
94
+ for b in sets:
95
+ if a<b:assert sets[a].isdisjoint(sets[b]),(a,b)
96
+ manifest['pairwise_group_disjoint']=True
97
+ manifest['exclusions']=dict(Counter(r['reason'] for r in excluded))
98
+ with (OUT/'excluded.jsonl').open('w') as f:
99
+ for r in excluded:f.write(json.dumps(r)+'\n')
100
+ (ART/'data-manifest.json').write_text(json.dumps(manifest,indent=2))
101
+ print('Tokenizing full eligible training-side data for length/alignment statistics',flush=True)
102
+ tokenizer=AutoTokenizer.from_pretrained('distilbert/distilbert-base-cased',revision=MODEL_REVISION)
103
+ tokenizer.save_pretrained(ROOT/'artifacts/tokenizer')
104
+ length_stats={}; entity_lengths=defaultdict(list); boundary_bad=Counter(); boundary_examples=[]
105
+ for name in ('train','validation','calibration_fit','calibration_select'):
106
+ lengths=[]
107
+ for base in range(0,len(splits[name]),256):
108
+ batch=splits[name][base:base+256]
109
+ encoded=tokenizer([r['text'] for r in batch],add_special_tokens=False,return_offsets_mapping=True,truncation=False,verbose=False)
110
+ for r,ids,offsets in zip(batch,encoded['input_ids'],encoded['offset_mapping']):
111
+ lengths.append(len(ids)); starts=[a for a,b in offsets]; ends=[b for a,b in offsets]
112
+ for s in r['spans']:
113
+ i=bisect.bisect_right(ends,s['start']); j=bisect.bisect_left(starts,s['end'])
114
+ entity_lengths[s['label']].append(j-i)
115
+ if i>=j or starts[i]!=s['start'] or ends[j-1]!=s['end']:
116
+ boundary_bad[s['label']]+=1
117
+ if len(boundary_examples)<30:boundary_examples.append({'id':r['id'],'span':s,'text':r['text'][s['start']:s['end']],'covering_offsets':offsets[i:j]})
118
+ length_stats[name]={'documents':len(lengths),'tokens':sum(lengths),'max':max(lengths,default=0),'quantiles':dict(zip(['p50','p90','p95','p99'],np.quantile(lengths,[.5,.9,.95,.99]).tolist())),'over_510_tokens':sum(x>510 for x in lengths),'characters':sum(len(r['text']) for r in splits[name])}
119
+ print(name,length_stats[name],flush=True)
120
+ manifest['tokenizer']={'model':'distilbert/distilbert-base-cased','revision':MODEL_REVISION,'model_max_length':tokenizer.model_max_length,'lengths':length_stats,'entity_tokens_by_label':{k:{'count':len(v),'max':max(v),'p99':float(np.quantile(v,.99))} for k,v in entity_lengths.items()},'unrepresentable_boundaries_by_label':dict(boundary_bad),'unrepresentable_boundary_examples':boundary_examples}
121
+ (ART/'data-manifest.json').write_text(json.dumps(manifest,indent=2))
122
+ print('DONE',json.dumps(manifest['tokenizer']),flush=True)
reproduction/scripts/probe_model.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prove MPS update, checkpoint reload, dynamic ONNX export before full training."""
2
+ import json
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+ import numpy as np
8
+ import onnxruntime as ort
9
+ import torch
10
+ from transformers import AutoModelForTokenClassification, AutoTokenizer
11
+ from pii.core import BIO, LABELS, MODEL_REVISION
12
+
13
+ torch.manual_seed(42) # Transformers TrainingArguments default seed.
14
+ out = Path("artifacts/probe")
15
+ out.mkdir(parents=True, exist_ok=True)
16
+ tokenizer = AutoTokenizer.from_pretrained("artifacts/backbone", local_files_only=True)
17
+ model = AutoModelForTokenClassification.from_pretrained("artifacts/backbone", num_labels=len(BIO), id2label=dict(enumerate(BIO)), label2id={v:k for k,v in enumerate(BIO)}, attn_implementation="eager", local_files_only=True).to("mps")
18
+ optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01)
19
+ report = {"model_revision": MODEL_REVISION, "parameters": sum(p.numel() for p in model.parameters()), "max_length": model.config.max_position_embeddings, "precision":"float32", "device":"mps", "benchmarks":[]}
20
+ before = model.classifier.weight.detach().clone()
21
+ # Official HF recipe batch=16; measure both short and maximum-length inputs.
22
+ for length in (32, model.config.max_position_embeddings):
23
+ x = torch.full((16, length), 100, dtype=torch.long, device="mps")
24
+ x[:,0], x[:,-1] = tokenizer.cls_token_id, tokenizer.sep_token_id
25
+ y = torch.zeros_like(x)
26
+ y[:,0], y[:,-1] = -100, -100
27
+ y[:,1] = 1
28
+ times = []
29
+ for stage in ("warmup", "measured"):
30
+ optimizer.zero_grad(set_to_none=True)
31
+ torch.mps.synchronize()
32
+ start = time.perf_counter()
33
+ loss = model(input_ids=x, attention_mask=torch.ones_like(x), labels=y).loss
34
+ loss.backward()
35
+ finite = all(torch.isfinite(p.grad).all().item() for p in model.parameters() if p.grad is not None)
36
+ optimizer.step()
37
+ torch.mps.synchronize()
38
+ times.append(time.perf_counter()-start)
39
+ assert torch.isfinite(loss).item() and finite
40
+ report["benchmarks"].append({"batch_size":16,"sequence_length":length,"step_seconds":times,"loss":loss.item(),"allocated_bytes":torch.mps.current_allocated_memory(),"driver_bytes":torch.mps.driver_allocated_memory()})
41
+ assert not torch.equal(before, model.classifier.weight)
42
+ model.eval()
43
+ sample = tokenizer("🙂 José Smith: jose@example.com", return_tensors="pt")
44
+ sample = {k:v.to("mps") for k,v in sample.items() if k in ("input_ids","attention_mask")}
45
+ with torch.no_grad():
46
+ reference = model(**sample).logits.cpu()
47
+ start = time.perf_counter()
48
+ model.save_pretrained(out / "checkpoint")
49
+ torch.save({"optimizer":optimizer.state_dict(),"rng":torch.get_rng_state(),"mps_rng":torch.mps.get_rng_state()}, out / "resume.pt")
50
+ report["checkpoint_seconds"] = time.perf_counter()-start
51
+ del optimizer
52
+ model = model.to("cpu")
53
+ reloaded = AutoModelForTokenClassification.from_pretrained(out / "checkpoint", local_files_only=True, attn_implementation="eager").to("mps").eval()
54
+ with torch.no_grad():
55
+ actual = reloaded(**sample).logits.cpu()
56
+ report["reload_max_abs_difference"] = (actual-reference).abs().max().item()
57
+ assert torch.equal(actual,reference)
58
+ del reloaded
59
+ torch.mps.empty_cache()
60
+ tokenizer.save_pretrained(out)
61
+ (out / "config.json").write_text(json.dumps({"labels":LABELS,"max_length":model.config.max_position_embeddings,"stride":0,"model_revision":MODEL_REVISION},indent=2))
62
+
63
+ class Export(torch.nn.Module):
64
+ def __init__(self, model):
65
+ super().__init__()
66
+ self.model=model
67
+ def forward(self, input_ids, attention_mask):
68
+ return self.model(input_ids=input_ids,attention_mask=attention_mask).logits
69
+
70
+ sample = {k:v.cpu() for k,v in sample.items()}
71
+ torch.onnx.export(Export(model).eval(), (sample["input_ids"],sample["attention_mask"]), str(out / "model.onnx"), input_names=["input_ids","attention_mask"], output_names=["logits"], dynamic_axes={"input_ids":{0:"batch",1:"sequence"},"attention_mask":{0:"batch",1:"sequence"},"logits":{0:"batch",1:"sequence"}}, opset_version=17, dynamo=False)
72
+ session=ort.InferenceSession(str(out / "model.onnx"),providers=["CPUExecutionProvider"])
73
+ report["export_checks"]=[]
74
+ for text in ("", "Alice Smith", "🙂 José Smith: jose@example.com", "Long ticket. " * 150):
75
+ encoded=tokenizer(text,return_tensors="pt",truncation=True,max_length=model.config.max_position_embeddings)
76
+ encoded={k:v for k,v in encoded.items() if k in ("input_ids","attention_mask")}
77
+ with torch.no_grad():
78
+ logits=model(**encoded).logits.numpy()
79
+ actual=session.run(None,{k:v.numpy() for k,v in encoded.items()})[0]
80
+ report["export_checks"].append({"tokens":encoded["input_ids"].shape[1],"max_abs_logit_difference":float(np.max(np.abs(logits-actual))),"argmax_identical":bool(np.array_equal(logits.argmax(-1),actual.argmax(-1)))})
81
+ assert np.array_equal(logits.argmax(-1),actual.argmax(-1))
82
+ (out / "evidence.json").write_text(json.dumps(report,indent=2)+"\n")
83
+ print(json.dumps(report,indent=2),flush=True)
reproduction/scripts/regex_baseline.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Pinned official DataFog regex baseline on shared email/phone scope."""
3
+ import argparse
4
+ import hashlib
5
+ import importlib.metadata
6
+ import json
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+ from pii.evaluation import evaluate, read_jsonl, write_json
12
+
13
+ VERSION = '4.8.1'
14
+ MAPPING = {'EMAIL': 'email', 'PHONE': 'phone_number'}
15
+
16
+
17
+ def scoped_gold(records):
18
+ return [{**r, 'spans':[s for s in r['spans'] if s['label'] in MAPPING.values()]} for r in records]
19
+
20
+
21
+ def predict(records):
22
+ import datafog
23
+ if importlib.metadata.version('datafog') != VERSION:
24
+ raise RuntimeError(f'Baseline requires datafog=={VERSION}')
25
+ for r in records:
26
+ result = datafog.scan(r['text'], engine='regex', entity_types=list(MAPPING))
27
+ findings = []
28
+ byte_offsets = [0]
29
+ for ch in r['text']:
30
+ byte_offsets.append(byte_offsets[-1] + len(ch.encode('utf-8')))
31
+ for e in result.entities:
32
+ if r['text'][e.start:e.end] != e.text:
33
+ raise ValueError('Upstream span/source mismatch')
34
+ findings.append(dict(label=MAPPING[e.type], start=byte_offsets[e.start], end=byte_offsets[e.end], raw_score=1.0, confidence=None))
35
+ yield dict(id=r['id'], text=r['text'], findings=findings, contains_pii=bool(findings), score_semantics='Constant match indicator from DataFog regex, not calibrated confidence')
36
+
37
+
38
+ def write_jsonl(path, records):
39
+ with open(path, 'w') as f:
40
+ for record in records:
41
+ f.write(json.dumps(record, ensure_ascii=False)+'\n')
42
+
43
+
44
+ if __name__ == '__main__':
45
+ parser = argparse.ArgumentParser(description=__doc__)
46
+ parser.add_argument('--gold', required=True)
47
+ parser.add_argument('--output-dir', required=True)
48
+ parser.add_argument('--model-predictions')
49
+ parser.add_argument('--model-calibration')
50
+ args = parser.parse_args()
51
+ output = Path(args.output_dir)
52
+ output.mkdir(parents=True, exist_ok=True)
53
+ gold = scoped_gold(read_jsonl(args.gold))
54
+ started = time.perf_counter()
55
+ predictions = list(predict(gold))
56
+ elapsed = time.perf_counter()-started
57
+ write_jsonl(output/'gold-email-phone.jsonl', gold)
58
+ write_jsonl(output/'regex-predictions.jsonl', predictions)
59
+ metrics = evaluate(gold, predictions)
60
+ # A regex match flag is not a probability; do not imply a calibration comparison.
61
+ for key in ('raw_reliability','confidence_reliability','reliability_population','confidence_semantics'):
62
+ metrics.pop(key, None)
63
+ metrics['score_semantics'] = 'Regex match indicator, no probability calibration evaluated'
64
+ metrics['comparison_scope'] = ['email','phone_number']
65
+ metrics['unsupported_labels'] = ['first_name','last_name','street_address','customer_id','account_number']
66
+ write_json(output/'regex-metrics.json', metrics)
67
+ provenance = dict(package='datafog', version=VERSION, official_repository='https://github.com/DataFog/datafog-python',
68
+ release='https://pypi.org/project/datafog/4.8.1/', input_sha256=hashlib.sha256(Path(args.gold).read_bytes()).hexdigest(),
69
+ documents=len(gold), elapsed_seconds=elapsed, API="datafog.scan(text, engine='regex', entity_types=['EMAIL','PHONE'])",
70
+ offset_conversion='Python character offsets converted to original UTF-8 byte offsets', dependencies={d.metadata['Name']:d.version for d in importlib.metadata.distributions()})
71
+ write_json(output/'regex-provenance.json', provenance)
72
+ if args.model_predictions:
73
+ model = read_jsonl(args.model_predictions)
74
+ model = [{**r, 'findings':[s for s in r['findings'] if s['label'] in MAPPING.values()]} for r in model]
75
+ calibration = json.loads(Path(args.model_calibration).read_text()) if args.model_calibration else None
76
+ model_metrics = evaluate(gold, model, calibration=calibration)
77
+ model_metrics['comparison_scope'] = ['email','phone_number']
78
+ write_json(output/'model-email-phone-metrics.json', model_metrics)
79
+ print(json.dumps({'documents':len(gold), 'seconds':elapsed, 'output_dir':str(output)}))
reproduction/scripts/report.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render final measured evidence as Markdown; absent evidence stays a blocker."""
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def load(root, name):
9
+ path = root / name
10
+ return json.loads(path.read_text()) if path.exists() else None
11
+
12
+
13
+ def number(value, decimals=4):
14
+ if value is None:
15
+ return 'Unavailable'
16
+ return f'{value:,.{decimals}f}' if isinstance(value, (int, float)) else str(value)
17
+
18
+
19
+ def table(headers, rows):
20
+ safe = lambda v: str(v).replace('|', '\\|').replace('\n', ' ')
21
+ return '\n'.join(['| '+' | '.join(headers)+' |', '| '+' | '.join(['---']*len(headers))+' |'] +
22
+ ['| '+' | '.join(safe(v) for v in row)+' |' for row in rows])
23
+
24
+
25
+ def render(root):
26
+ paths = {
27
+ 'test':'artifacts/evaluation/test.json', 'fixtures':'artifacts/evaluation/support-fixtures.json',
28
+ 'regex':'artifacts/regex-test/regex-metrics.json', 'model_shared':'artifacts/regex-test/model-email-phone-metrics.json',
29
+ 'calibration':'artifacts/bundle/calibration.json', 'training':'artifacts/training/complete.json',
30
+ 'data':'artifacts/data-manifest.json', 'parity':'artifacts/bundle/rust-parity.json',
31
+ 'benchmark':'artifacts/native-benchmark.json', 'training_check':'artifacts/training-check.json',
32
+ 'boundary':'artifacts/boundary-proof.json'}
33
+ a = {k:load(root,p) for k,p in paths.items()}
34
+ history_path = root/'artifacts/training/history.jsonl'
35
+ history = [json.loads(line) for line in history_path.read_text().splitlines() if line.strip()] if history_path.exists() else []
36
+ if not history and a['training']:
37
+ history = a['training'].get('history', [])
38
+ evidence = lambda key: f'[{paths[key]}]({paths[key]})'
39
+ lines = ['# DataFog PII prototype evaluation', '',
40
+ 'This is an evaluated prototype, not a production-readiness claim. All numbers below come from saved artifacts; absent evidence is explicitly marked unavailable.', '']
41
+ required = ('test','fixtures','regex','model_shared','calibration','training','data','parity','benchmark')
42
+ missing = [paths[k] for k in required if a[k] is None]
43
+ lines += [('Required evaluation artifacts are present.' if not missing else '**Incomplete evidence:** '+', '.join(missing)), '']
44
+ criteria = []
45
+ def criterion(title, passed, keys, detail):
46
+ criteria.append([title, 'Evidence present' if passed else 'Unresolved / missing evidence', ', '.join(evidence(k) for k in keys), detail])
47
+ criterion('Pinned data and disjoint document families', bool(a['data'] and a['data'].get('pairwise_group_disjoint')), ['data'], 'Structural validation and split rules are saved; annotation semantics remain imperfect.')
48
+ criterion('Trained checkpoint and resumability', bool(a['training'] and a['training_check']), ['training','training_check'], 'Completion plus separate optimizer/RNG resume diagnostic; inspect reported MPS parameter differences.')
49
+ p=a['parity'] or {}
50
+ criterion('Native Rust and Python parity', all(p.get(k) for k in ('token_ids_masks_global_byte_offsets_parity','findings_redaction_parity','malformed_request_explicit_error_and_recovery')), ['parity'], 'Checks tokenizer/global byte offsets, outputs/redaction, and explicit error recovery; parity does not prove quality.')
51
+ criterion('Complete-input boundary behavior', bool(a['boundary']), ['boundary'], 'Manual global-offset window construction; finite fixture evidence and representability limits apply.')
52
+ criterion('Independent calibration and experimental threshold', bool(a['calibration'] and a['test']), ['calibration','test'], 'Fit/select partitions are separate; final reliability is reported on held-out predicted spans.')
53
+ criterion('Held-out quality and support mechanics', bool(a['test'] and a['fixtures']), ['test','fixtures'], 'Synthetic population only; no reviewed private tickets.')
54
+ criterion('Existing regex comparison', bool(a['regex'] and a['model_shared']), ['regex','model_shared'], 'Official published DataFog 4.8.1; email and phone only.')
55
+ criterion('Native resource measurements', bool(a['benchmark']), ['benchmark'], 'Local observed load, warm request latency, memory, and bundle size; no performance target assumed.')
56
+ lines += [table(['Contract criterion','Status','Evidence','Interpretation / limitation'],criteria),'']
57
+ data=a['data'] or {}
58
+ lines += ['## Data and execution', '', f"Dataset: `{data.get('source','Unavailable')}` revision `{data.get('revision','Unavailable')}`. License: {data.get('license','Unavailable')}. {data.get('attribution','')}", '']
59
+ if data.get('splits'):
60
+ lines += [table(['Partition','Documents','Target-clean documents'],[(k,v.get('documents'),v.get('negative_documents')) for k,v in data['splits'].items()]),'']
61
+ if data.get('exclusions'):
62
+ lines += ['Structural exclusions: '+', '.join(f'{k}: {v:,}' for k,v in data['exclusions'].items())+'.','']
63
+ if history:
64
+ lines += [table(['Epoch','Train mean batch loss','Validation token loss','Selected at epoch','Elapsed seconds'],
65
+ [(r.get('epoch'),number(r.get('train_mean_batch_loss')),number(r.get('validation_token_loss')),r.get('selected'),number(r.get('elapsed_seconds'),1)) for r in history]),'']
66
+ else:
67
+ lines += ['Training history unavailable.','']
68
+ if a['training']:
69
+ lines += [f"Training elapsed: {number(a['training'].get('elapsed_seconds'),1)} seconds. Best validation token loss: {number(a['training'].get('best_validation_loss'))}.",'']
70
+ lines += ['Corrective iterations: two of the three-round fuse used. Round 1 fixed tokenizer overflow by manually constructing complete-input windows; round 2 kept the ONNX export wrapper and Python reference in evaluation mode. Neither correction tuned against final test outcomes. Further corrective work must remain within the agreed fuse.','']
71
+ test=a['test']
72
+ lines += ['## Held-out detection', '']
73
+ if test:
74
+ metrics=[('overall',test.get('overall',{})), *test.get('per_label',{}).items()]
75
+ lines += [table(['Label','Gold entities','Predicted','TP','FP','FN','Precision','Recall','F1'],
76
+ [(label,m.get('support'),m.get('predicted'),m.get('tp'),m.get('fp'),m.get('fn'),number(m.get('precision')),number(m.get('recall')),number(m.get('f1'))) for label,m in metrics]),'']
77
+ cov=test.get('redaction_byte_coverage',{})
78
+ lines += [f"UTF-8 byte-union redaction precision {number(cov.get('precision'))}, recall {number(cov.get('recall'))}, F1 {number(cov.get('f1'))}. Missed gold bytes: {number(cov.get('fn'),0)}; unnecessary redaction bytes: {number(cov.get('fp'),0)}. Coverage ignores labels and weights multibyte characters by byte length.",'']
79
+ docs=test.get('documents',{})
80
+ lines += [table(['Document measure','Count'],[(k,number(v,0)) for k,v in docs.items()]),'',
81
+ 'Document false negatives mean no accepted span on a document containing target PII; partial misses are counted separately in `with_any_missed_entity`. False positives mean at least one accepted span on a target-clean document.','']
82
+ else:
83
+ lines += ['Held-out detection results unavailable.','']
84
+ lines += ['## Confidence and operating point','']
85
+ c=a['calibration']
86
+ if c:
87
+ lines += [f"Method: {c.get('method','Unavailable')}, pooled across labels. Experimental acceptance threshold: {number(c.get('threshold'),8)}. Objective: {c.get('threshold_objective','Unavailable')}.",'',
88
+ f"Calibration fit support: {number(c.get('fit_documents'),0)} documents, {number(c.get('fit_predicted_spans'),0)} predicted spans, {number(c.get('fit_correct_spans'),0)} exact matches. Selection support: {number(c.get('select_documents'),0)} documents.",'']
89
+ lines += [f"Calibration warning: {warning}" for warning in c.get('warnings',[])]
90
+ else:
91
+ lines += ['Calibration artifact unavailable.','']
92
+ if test:
93
+ raw=test.get('raw_reliability',{}); calibrated=test.get('confidence_reliability',{})
94
+ lines += [table(['Score','Predicted-span support','Brier','ECE'],[(name,r.get('support'),number(r.get('brier'),6),number(r.get('ece'),6)) for name,r in [('Raw token-derived span score',raw),('Calibrated exact-span score',calibrated)]]),'']
95
+ rows=[]
96
+ for i in range(10):
97
+ rb=raw.get('bins',[])[i] if len(raw.get('bins',[]))>i else {}
98
+ cb=calibrated.get('bins',[])[i] if len(calibrated.get('bins',[]))>i else {}
99
+ rows.append([f'[{i/10:.1f}, {(i+1)/10:.1f}'+(']' if i==9 else ')'),rb.get('support','Unavailable'),number(rb.get('mean_score')),number(rb.get('accuracy')),cb.get('support','Unavailable'),number(cb.get('mean_score')),number(cb.get('accuracy'))])
100
+ lines += [table(['Confidence bin','Raw n','Raw mean','Raw accuracy','Calibrated n','Calibrated mean','Calibrated accuracy'],rows),'']
101
+ lines += ['Reliability uses all predicted spans before thresholding and exact label/boundary correctness. Missed entities are absent from this table: recall must be read separately. Ten equal-width bins make ECE bin-dependent. Calibration is conditional on this synthetic evaluation population and annotation quality; the threshold is not a production default.','']
102
+ lines += ['## Existing regex comparison','']
103
+ if a['regex'] and a['model_shared']:
104
+ rows=[]
105
+ for name,key in [('DataFog 4.8.1 regex','regex'),('Trained model, experimental threshold','model_shared')]:
106
+ for label in ['overall','email','phone_number']:
107
+ m=a[key].get('overall',{}) if label=='overall' else a[key].get('per_label',{}).get(label,{})
108
+ rows.append([name,label,m.get('support'),number(m.get('precision')),number(m.get('recall')),number(m.get('f1'))])
109
+ lines += [table(['System','Scope','Gold entities','Precision','Recall','F1'],rows),'']
110
+ else:
111
+ lines += ['Complete same-population, same-label comparison unavailable.','']
112
+ lines += ['Only email and phone are supported by this existing regex baseline within the seven-label contract. First name, last name, street address, customer ID, and account number are excluded from both sides of this head-to-head comparison. All documents remain in the comparison. Constant regex match indicators are not calibrated probabilities. See [baseline provenance](docs/regex-baseline.md).','']
113
+ lines += ['## Synthetic support fixtures','']
114
+ if a['fixtures']:
115
+ f=a['fixtures']; m=f.get('overall',{})
116
+ lines += [f"{number(f.get('documents',{}).get('total'),0)} fixture documents; exact-span precision {number(m.get('precision'))}, recall {number(m.get('recall'))}, F1 {number(m.get('f1'))}. Gold entity support: {number(m.get('support'),0)}.",'']
117
+ else:
118
+ lines += ['Fixture predictions unavailable.','']
119
+ lines += ['These hand-authored cases include Unicode signatures, pasted fields, multiline addresses, repeated entities, clean numeric text, and long inputs. They exercise mechanics and expose case-specific failures; they do not establish real customer-support quality.','']
120
+ lines += ['## Native resource measurements','']
121
+ b=a['benchmark']
122
+ if b:
123
+ lines += [table(['Measurement','Observed value'],[(k,number(b.get(k),3 if k=='cold_load_seconds' else 0)) for k in ('bundle_bytes','cold_load_seconds','peak_rss_bytes')]),'']
124
+ warm=b.get('warm',b.get('requests',b.get('warm_requests',[])))
125
+ if isinstance(warm,dict): warm=warm.get('requests',[])
126
+ if isinstance(warm,list) and warm:
127
+ lines += [table(['Input UTF-8 bytes','Warm end-to-end seconds'],[(r.get('bytes','Unavailable'),number(r.get('latency_seconds'),6)) for r in warm]),'']
128
+ if b.get('note'): lines += [str(b['note']),'']
129
+ else:
130
+ lines += ['Native resource measurements unavailable.','']
131
+ if a['parity']:
132
+ lines += [f"Maximum absolute Python/Rust logit difference on parity cases: {number(p.get('max_abs_logit_difference'),8)}. Token IDs, masks and global byte offsets agree: {p.get('token_ids_masks_global_byte_offsets_parity','Unavailable')}; findings/redaction agree: {p.get('findings_redaction_parity','Unavailable')}.",'']
133
+ unrep=data.get('tokenizer',{}).get('unrepresentable_boundaries_by_label',{})
134
+ lines += ['## Remaining limitations','',
135
+ '- Nemotron is broad synthetic data with semantic annotation errors. Structural filtering cannot prove that labels are semantically correct or complete.',
136
+ '- Split allocation uses documented label-support heuristics and grouped families. It does not guarantee statistical precision or eliminate every possible near-duplicate.',
137
+ '- No private customer tickets were supplied or uploaded; real-world English customer-support generalization remains unproven.',
138
+ '- Token boundaries cannot represent every character boundary. Observed training-source unrepresentable boundaries: '+(', '.join(f'{k}={v}' for k,v in unrep.items()) if unrep else 'unavailable')+'.',
139
+ '- Native inference requires the packaged runtime and pinned tokenizer/configuration. A successful fixture parity check is not a universal numerical guarantee.',
140
+ '- `contains_pii` means at least one accepted finding among the seven selected labels; it is not an independently calibrated probability or assurance that a document contains no other sensitive information.',
141
+ '- No existing DataFog checkout was modified, no Git repository was created, and no model was published as part of this standalone prototype.', '',
142
+ 'Reproduction commands and artifact locations are in [README.md](README.md).']
143
+ return '\n'.join(lines)+'\n'
144
+
145
+
146
+ if __name__ == '__main__':
147
+ parser=argparse.ArgumentParser(description=__doc__)
148
+ parser.add_argument('--root',type=Path,default=Path(__file__).resolve().parents[1])
149
+ parser.add_argument('--output',type=Path)
150
+ args=parser.parse_args()
151
+ output=args.output or args.root/'REPORT.md'
152
+ output.parent.mkdir(parents=True,exist_ok=True)
153
+ output.write_text(render(args.root))
154
+ print(output)
reproduction/scripts/run_pipeline.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent sequential prototype run; records failures without hiding them."""
2
+ import datetime
3
+ import hashlib
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ ROOT=Path(__file__).resolve().parents[1]
10
+ PY=str(ROOT/".venv/bin/python")
11
+ STATE=ROOT/"artifacts/pipeline"
12
+
13
+ def now():return datetime.datetime.now(datetime.timezone.utc).isoformat()
14
+
15
+ def digest(path):
16
+ h=hashlib.sha256()
17
+ with path.open("rb") as f:
18
+ for block in iter(lambda:f.read(1024*1024),b""):h.update(block)
19
+ return h.hexdigest()
20
+
21
+ def stage(name,command):
22
+ marker=STATE/f"{name}.done.json"
23
+ if marker.exists():return
24
+ event={"stage":name,"status":"running","started_at":now(),"command":command}
25
+ (STATE/"status.json").write_text(json.dumps(event,indent=2)+"\n")
26
+ print(json.dumps(event),flush=True)
27
+ with (STATE/f"{name}.log").open("a") as log:
28
+ completed=subprocess.run(command,cwd=ROOT,stdout=log,stderr=subprocess.STDOUT)
29
+ event.update(finished_at=now(),returncode=completed.returncode,status="complete" if completed.returncode==0 else "failed")
30
+ (STATE/"status.json").write_text(json.dumps(event,indent=2)+"\n")
31
+ if completed.returncode:raise RuntimeError(f"{name} failed; inspect {STATE/name}.log")
32
+ marker.write_text(json.dumps(event,indent=2)+"\n")
33
+
34
+ def main():
35
+ STATE.mkdir(parents=True,exist_ok=True)
36
+ training_command=[PY,"scripts/train.py","--stride","42"]
37
+ checkpoints=sorted((ROOT/"artifacts/training").glob("epoch-*/state.pt"),key=lambda p:int(p.parent.name.split("-")[-1]))
38
+ if checkpoints:training_command.extend(["--resume",str(checkpoints[-1].parent)])
39
+ stage("train",training_command)
40
+ stage("export",[PY,"scripts/export_model.py"])
41
+ for split in ("calibration_fit","calibration_select"):
42
+ stage("predict-"+split,[PY,"scripts/predict.py","--input",f"data/prepared/{split}.jsonl","--output",f"artifacts/predictions/{split}.jsonl"])
43
+ stage("calibrate",[PY,"scripts/calibrate.py","--fit-gold","data/prepared/calibration_fit.jsonl","--fit-predictions","artifacts/predictions/calibration_fit.jsonl","--select-gold","data/prepared/calibration_select.jsonl","--select-predictions","artifacts/predictions/calibration_select.jsonl","--output","artifacts/bundle/calibration.json"])
44
+ bundle=ROOT/"artifacts/bundle"
45
+ config=json.loads((bundle/"config.json").read_text())
46
+ config.update(calibration_file="calibration.json",status="experimental prototype; demonstration threshold only")
47
+ (bundle/"config.json").write_text(json.dumps(config,indent=2)+"\n")
48
+ stage("final-parity",[PY,"scripts/check_rust_parity.py","artifacts/bundle","--output","artifacts/bundle/rust-parity.json"])
49
+ freeze=STATE/"evaluation-freeze.json"
50
+ files=[p for p in bundle.rglob("*") if p.is_file()]+[ROOT/"pii/core.py",ROOT/"pii/evaluation.py",ROOT/"scripts/predict.py",ROOT/"rust/target/release/datafog-pii"]
51
+ hashes={str(p.relative_to(ROOT)):digest(p) for p in files}
52
+ if freeze.exists():
53
+ original=json.loads(freeze.read_text())
54
+ if any(hashes.get(path)!=value for path,value in original["sha256"].items()):raise RuntimeError("Frozen evaluation inputs changed; owner-visible diagnostic review required")
55
+ else:freeze.write_text(json.dumps({"frozen_at":now(),"sha256":hashes,"policy":"No tuning against test results"},indent=2)+"\n")
56
+ for split,path in (("support-fixtures","data/support-fixtures.jsonl"),("test","data/prepared/test.jsonl")):
57
+ prediction=f"artifacts/predictions/{split}.jsonl"
58
+ stage("predict-"+split,[PY,"scripts/predict.py","--input",path,"--output",prediction])
59
+ stage("evaluate-"+split,[PY,"scripts/evaluate.py","--gold",path,"--predictions",prediction,"--calibration","artifacts/bundle/calibration.json","--output",f"artifacts/evaluation/{split}.json"])
60
+ stage("regex-test",[str(ROOT/"work/regex-env/bin/python"),"scripts/regex_baseline.py","--gold","data/prepared/test.jsonl","--output-dir","artifacts/regex-test","--model-predictions","artifacts/predictions/test.jsonl","--model-calibration","artifacts/bundle/calibration.json"])
61
+ stage("benchmark",[PY,"scripts/benchmark_native.py"])
62
+ stage("report",[PY,"scripts/report.py"])
63
+ (STATE/"status.json").write_text(json.dumps({"status":"complete","finished_at":now(),"next":"Review evidence and final report against contract"},indent=2)+"\n")
64
+
65
+ if __name__=="__main__":main()
reproduction/scripts/train.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full supervised training using the pinned HF token-classification recipe."""
2
+ import argparse
3
+ import json
4
+ import math
5
+ import random
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ import numpy as np
11
+ import torch
12
+ from transformers import AutoModelForTokenClassification, AutoTokenizer, DataCollatorForTokenClassification
13
+ from pii.core import BIO, MODEL_REVISION, load_jsonl, encode_windows
14
+
15
+ def prepare(records, tokenizer, max_length, stride):
16
+ chunks=[]
17
+ stats={"documents":len(records),"chunks":0,"tokens":0,"ignored_boundary_tokens":0,"partial_window_entity_tokens":0}
18
+ for record in records:
19
+ encoded=encode_windows(tokenizer,record["text"],max_length,stride)
20
+ for ids, mask, offsets in zip(encoded["input_ids"],encoded["attention_mask"],encoded["offset_mapping"]):
21
+ content=[(a,b) for a,b in offsets if a!=b]
22
+ left,right=content[0][0] if content else 0,content[-1][1] if content else 0
23
+ labels=[]
24
+ for a,b in offsets:
25
+ label=0
26
+ if a==b:
27
+ labels.append(-100)
28
+ continue
29
+ for span in record["spans"]:
30
+ s,e=span["start"],span["end"]
31
+ if a<e and s<b:
32
+ if not (s<=a and b<=e):
33
+ label=-100
34
+ stats["ignored_boundary_tokens"]+=1
35
+ elif s<left or e>right:
36
+ label=-100
37
+ stats["partial_window_entity_tokens"]+=1
38
+ else:
39
+ # First representable token of the entity gets B.
40
+ earlier=any(s<=aa<a and bb<=e and aa!=bb for aa,bb in offsets)
41
+ label=BIO.index(("I-" if earlier else "B-")+span["label"])
42
+ break
43
+ labels.append(label)
44
+ chunks.append({"input_ids":ids,"attention_mask":mask,"labels":labels})
45
+ stats["tokens"]+=len(ids)
46
+ stats["chunks"]=len(chunks)
47
+ return chunks,stats
48
+
49
+ def main():
50
+ p=argparse.ArgumentParser()
51
+ p.add_argument("--stride",type=int,required=True)
52
+ p.add_argument("--device",default="mps")
53
+ p.add_argument("--resume",type=Path)
54
+ p.add_argument("--prepare-only",action="store_true")
55
+ args=p.parse_args()
56
+ out=Path("artifacts/training");out.mkdir(parents=True,exist_ok=True)
57
+ seed=42
58
+ random.seed(seed);np.random.seed(seed);torch.manual_seed(seed)
59
+ tokenizer=AutoTokenizer.from_pretrained("artifacts/backbone",local_files_only=True)
60
+ config={"seed":seed,"batch_size":16,"epochs":2,"learning_rate":2e-5,"weight_decay":0.01,"max_length":512,"stride":args.stride,"device":args.device,"precision":"float32","model_revision":MODEL_REVISION,"recipe":"https://huggingface.co/docs/transformers/tasks/token_classification","stopping_rule":"Two epochs from official recipe; select lowest validation token loss. This is a prototype recipe hypothesis, not an optimum.","checkpoint_cadence":"Each epoch per official recipe; measured probe checkpoint cost 0.733s.","selection_metric":"validation token cross entropy"}
61
+ cache=out/f"chunks-manual-stride-{args.stride}.pt"
62
+ if cache.exists():
63
+ prepared=torch.load(cache,weights_only=False)
64
+ else:
65
+ prepared={}
66
+ for name in ("train","validation"):
67
+ prepared[name],prepared[name+"_stats"]=prepare(load_jsonl(f"data/prepared/{name}.jsonl"),tokenizer,512,args.stride)
68
+ print(json.dumps({name:prepared[name+"_stats"]}),flush=True)
69
+ torch.save(prepared,cache)
70
+ config["data_stats"]={name:prepared[name+"_stats"] for name in ("train","validation")}
71
+ config["estimated_training_seconds_max_length_probe"]=math.ceil(len(prepared["train"])/16)*2*0.558059167
72
+ (out/"run-config.json").write_text(json.dumps(config,indent=2)+"\n")
73
+ if args.prepare_only:
74
+ print(json.dumps(config,indent=2),flush=True);return
75
+ if config["estimated_training_seconds_max_length_probe"]>24*3600:
76
+ raise RuntimeError("Estimated run exceeds user-approved 24h limit; owner decision required")
77
+ model=AutoModelForTokenClassification.from_pretrained(args.resume / "model" if args.resume else "artifacts/backbone",num_labels=len(BIO),id2label=dict(enumerate(BIO)),label2id={v:k for k,v in enumerate(BIO)},local_files_only=True,attn_implementation="eager").to(args.device)
78
+ optimizer=torch.optim.AdamW(model.parameters(),lr=config["learning_rate"],weight_decay=config["weight_decay"])
79
+ steps_per_epoch=math.ceil(len(prepared["train"])/16)
80
+ scheduler=torch.optim.lr_scheduler.LambdaLR(optimizer,lambda step:max(0.0,1-step/(steps_per_epoch*config["epochs"])))
81
+ collate=DataCollatorForTokenClassification(tokenizer,padding=True,return_tensors="pt")
82
+ start_epoch,best_loss,global_step=0,float("inf"),0
83
+ if args.resume:
84
+ state=torch.load(args.resume/"state.pt",map_location="cpu",weights_only=False)
85
+ optimizer.load_state_dict(state["optimizer"]);scheduler.load_state_dict(state["scheduler"])
86
+ random.setstate(state["random"]);np.random.set_state(state["numpy"]);torch.set_rng_state(state["torch"])
87
+ if args.device=="mps":torch.mps.set_rng_state(state["mps"])
88
+ start_epoch,best_loss,global_step=state["epoch"],state["best_loss"],state["step"]
89
+ history=[]
90
+ started=time.perf_counter()
91
+ for epoch in range(start_epoch,config["epochs"]):
92
+ model.train()
93
+ order=list(range(len(prepared["train"])));random.shuffle(order)
94
+ running=0.0
95
+ for step,begin in enumerate(range(0,len(order),16)):
96
+ batch=collate([prepared["train"][i] for i in order[begin:begin+16]])
97
+ batch={k:v.to(args.device) for k,v in batch.items()}
98
+ optimizer.zero_grad(set_to_none=True)
99
+ loss=model(**batch).loss
100
+ if not torch.isfinite(loss):raise RuntimeError("nonfinite loss")
101
+ loss.backward()
102
+ torch.nn.utils.clip_grad_norm_(model.parameters(),1.0,error_if_nonfinite=True) # Trainer default.
103
+ optimizer.step();scheduler.step()
104
+ running+=loss.item();global_step+=1
105
+ # Persist current progress every batch; no logging interval assumption.
106
+ (out/"progress.json").write_text(json.dumps({"epoch":epoch+1,"step_in_epoch":step+1,"steps_per_epoch":steps_per_epoch,"global_step":global_step,"loss":loss.item(),"elapsed_seconds":time.perf_counter()-started})+"\n")
107
+ model.eval();total_loss,total_tokens=0.0,0
108
+ with torch.no_grad():
109
+ for begin in range(0,len(prepared["validation"]),16):
110
+ batch=collate(prepared["validation"][begin:begin+16]);batch={k:v.to(args.device) for k,v in batch.items()}
111
+ count=(batch["labels"]!=-100).sum().item()
112
+ total_loss+=model(**batch).loss.item()*count;total_tokens+=count
113
+ val_loss=total_loss/total_tokens
114
+ selected=val_loss<best_loss
115
+ if selected:
116
+ best_loss=val_loss
117
+ model.save_pretrained(out/"best");tokenizer.save_pretrained(out/"best")
118
+ checkpoint=out/f"epoch-{epoch+1}"
119
+ model.save_pretrained(checkpoint/"model")
120
+ torch.save({"optimizer":optimizer.state_dict(),"scheduler":scheduler.state_dict(),"random":random.getstate(),"numpy":np.random.get_state(),"torch":torch.get_rng_state(),"mps":torch.mps.get_rng_state() if args.device=="mps" else None,"epoch":epoch+1,"best_loss":best_loss,"step":global_step},checkpoint/"state.pt")
121
+ event={"epoch":epoch+1,"train_mean_batch_loss":running/steps_per_epoch,"validation_token_loss":val_loss,"selected":selected,"elapsed_seconds":time.perf_counter()-started}
122
+ history.append(event)
123
+ with (out/"history.jsonl").open("a") as handle:handle.write(json.dumps(event)+"\n")
124
+ print(json.dumps(event),flush=True)
125
+ (out/"complete.json").write_text(json.dumps({"history":history,"best_validation_loss":best_loss,"elapsed_seconds":time.perf_counter()-started},indent=2)+"\n")
126
+
127
+ if __name__=="__main__":main()
reproduction/tests/test_data.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pii.data import validate, family_key
3
+
4
+ class DataValidation(unittest.TestCase):
5
+ def test_python_literal_and_unicode(self):
6
+ text='😊 José'
7
+ spans,error=validate({'text':text,'spans':"[{'start':2,'end':6,'text':'José','label':'first_name'}]"})
8
+ self.assertIsNone(error); self.assertEqual(text[spans[0]['start']:spans[0]['end']],'José')
9
+ def test_invalid_offset_excluded(self):
10
+ self.assertEqual(validate({'text':'José','spans':"[{'start':0,'end':3,'text':'José','label':'first_name'}]"})[1],'source_slice_mismatch')
11
+ def test_overlap_excluded(self):
12
+ spans=[{'start':0,'end':4,'text':'John','label':'first_name'},{'start':0,'end':4,'text':'John','label':'last_name'}]
13
+ self.assertEqual(validate({'text':'John','spans':repr(spans)})[1],'duplicate_or_overlap')
14
+ def test_no_eval(self):
15
+ self.assertEqual(validate({'text':'x','spans':"__import__('os').system('false')"})[1],'parse')
16
+ def test_family_hides_values(self):
17
+ a=[{'start':6,'end':10,'label':'first_name'}]; b=[{'start':6,'end':10,'label':'first_name'}]
18
+ self.assertEqual(family_key('Name: John',a),family_key('Name: Jane',b))
19
+ self.assertNotEqual(family_key('Name: John',a),family_key('User: John',a))
20
+ if __name__=='__main__':unittest.main()
reproduction/tests/test_evaluation.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pii.evaluation import evaluate, apply_calibration, fit_calibration, read_jsonl
3
+
4
+
5
+ def pred(gold, score=.8):
6
+ return {'id': gold['id'], 'text': gold['text'], 'findings': [dict(label=s['label'], start=len(gold['text'][:s['start']].encode()), end=len(gold['text'][:s['end']].encode()), raw_score=score, confidence=None) for s in gold['spans']]}
7
+
8
+
9
+ class EvaluationTests(unittest.TestCase):
10
+ def test_unicode_and_fixtures(self):
11
+ gold = read_jsonl('data/support-fixtures.jsonl')
12
+ result = evaluate(gold, [pred(g) for g in gold])
13
+ self.assertEqual(result['overall']['f1'], 1)
14
+ self.assertEqual(result['redaction_byte_coverage']['f1'], 1)
15
+ self.assertGreater(result['documents']['clean_support'], 0)
16
+ self.assertEqual(result['documents']['false_positive'], 0)
17
+ self.assertEqual(result['documents']['false_negative'], 0)
18
+
19
+ def test_partial_wrong_label_and_clean_doc(self):
20
+ gold = [{'id': 'a', 'text': 'é Alice', 'spans': [{'label':'first_name', 'start':2, 'end':7}]}, {'id':'b', 'text':'version 123', 'spans':[]}]
21
+ predictions = [{'id':'a', 'text':'é Alice', 'findings':[{'label':'last_name', 'start':3, 'end':6, 'raw_score':.7}]}, {'id':'b', 'text':'version 123', 'findings':[{'label':'customer_id', 'start':8, 'end':11, 'raw_score':.9}]}]
22
+ result = evaluate(gold, predictions)
23
+ self.assertEqual(result['overall']['tp'], 0)
24
+ self.assertEqual(result['redaction_byte_coverage']['tp'], 3)
25
+ self.assertEqual(result['redaction_byte_coverage']['fn'], 2)
26
+ self.assertEqual(result['redaction_byte_coverage']['fp'], 3)
27
+ self.assertEqual(result['documents']['false_positive'], 1)
28
+ self.assertEqual(result['documents']['with_any_missed_entity'], 1)
29
+ self.assertEqual(result['documents']['false_negative'], 0)
30
+ self.assertAlmostEqual(result['raw_reliability']['brier'], .65)
31
+ self.assertEqual(evaluate(gold, predictions, threshold=.8)['documents']['false_negative'], 1)
32
+
33
+ def test_fail_closed_on_missing_and_bad_boundaries(self):
34
+ gold = [{'id':'a', 'text':'é', 'spans':[]}]
35
+ with self.assertRaises(ValueError):
36
+ evaluate(gold, [])
37
+ with self.assertRaises(ValueError):
38
+ evaluate(gold, [{'id':'a', 'text':'é', 'findings':[{'label':'first_name','start':1,'end':2,'raw_score':.5}]}])
39
+
40
+ def test_independent_calibration_and_interpolation(self):
41
+ fg = [{'id':'fit', 'text':'Alice Bob', 'spans':[{'label':'first_name','start':0,'end':5}]}]
42
+ fp = [{'id':'fit','text':'Alice Bob','findings':[{'label':'first_name','start':0,'end':5,'raw_score':.9}, {'label':'last_name','start':6,'end':9,'raw_score':.1}]}]
43
+ sg = [{'id':'select','text':'Carol Dave', 'spans':[{'label':'first_name','start':0,'end':5}]}]
44
+ sp = [{'id':'select','text':'Carol Dave','findings':[{'label':'first_name','start':0,'end':5,'raw_score':.8}, {'label':'last_name','start':6,'end':10,'raw_score':.2}]}]
45
+ artifact = fit_calibration(fg, fp, sg, sp)
46
+ self.assertEqual(artifact['select_operating_point']['f1'], 1)
47
+ self.assertAlmostEqual(apply_calibration(.5, artifact), .5)
48
+ self.assertEqual(apply_calibration(0, artifact), 0)
49
+ self.assertEqual(apply_calibration(1, artifact), 1)
50
+ with self.assertRaises(ValueError):
51
+ fit_calibration(fg, fp, fg, fp)
52
+ self.assertEqual(evaluate(sg, sp, calibration=artifact)['overall']['f1'], 1)
53
+
54
+
55
+ if __name__ == '__main__':
56
+ unittest.main()
reproduction/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
runtime/Cargo.lock ADDED
@@ -0,0 +1,877 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "ahash"
7
+ version = "0.8.12"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10
+ dependencies = [
11
+ "cfg-if",
12
+ "getrandom",
13
+ "once_cell",
14
+ "serde",
15
+ "version_check",
16
+ "zerocopy",
17
+ ]
18
+
19
+ [[package]]
20
+ name = "aho-corasick"
21
+ version = "1.1.5"
22
+ source = "registry+https://github.com/rust-lang/crates.io-index"
23
+ checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
24
+ dependencies = [
25
+ "memchr",
26
+ ]
27
+
28
+ [[package]]
29
+ name = "anyhow"
30
+ version = "1.0.104"
31
+ source = "registry+https://github.com/rust-lang/crates.io-index"
32
+ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
33
+
34
+ [[package]]
35
+ name = "autocfg"
36
+ version = "1.5.1"
37
+ source = "registry+https://github.com/rust-lang/crates.io-index"
38
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
39
+
40
+ [[package]]
41
+ name = "base64"
42
+ version = "0.13.1"
43
+ source = "registry+https://github.com/rust-lang/crates.io-index"
44
+ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
45
+
46
+ [[package]]
47
+ name = "bitflags"
48
+ version = "2.13.2"
49
+ source = "registry+https://github.com/rust-lang/crates.io-index"
50
+ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
51
+
52
+ [[package]]
53
+ name = "castaway"
54
+ version = "0.2.4"
55
+ source = "registry+https://github.com/rust-lang/crates.io-index"
56
+ checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
57
+ dependencies = [
58
+ "rustversion",
59
+ ]
60
+
61
+ [[package]]
62
+ name = "cc"
63
+ version = "1.5.1"
64
+ source = "registry+https://github.com/rust-lang/crates.io-index"
65
+ checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
66
+ dependencies = [
67
+ "find-msvc-tools",
68
+ "shlex",
69
+ ]
70
+
71
+ [[package]]
72
+ name = "cfg-if"
73
+ version = "1.0.5"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
76
+
77
+ [[package]]
78
+ name = "compact_str"
79
+ version = "0.9.1"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
82
+ dependencies = [
83
+ "castaway",
84
+ "cfg-if",
85
+ "itoa",
86
+ "rustversion",
87
+ "ryu",
88
+ "serde",
89
+ "static_assertions",
90
+ ]
91
+
92
+ [[package]]
93
+ name = "crossbeam-deque"
94
+ version = "0.8.8"
95
+ source = "registry+https://github.com/rust-lang/crates.io-index"
96
+ checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
97
+ dependencies = [
98
+ "crossbeam-epoch",
99
+ "crossbeam-utils",
100
+ ]
101
+
102
+ [[package]]
103
+ name = "crossbeam-epoch"
104
+ version = "0.9.21"
105
+ source = "registry+https://github.com/rust-lang/crates.io-index"
106
+ checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
107
+ dependencies = [
108
+ "crossbeam-utils",
109
+ ]
110
+
111
+ [[package]]
112
+ name = "crossbeam-utils"
113
+ version = "0.8.23"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
116
+
117
+ [[package]]
118
+ name = "daachorse"
119
+ version = "3.0.3"
120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
121
+ checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d"
122
+
123
+ [[package]]
124
+ name = "darling"
125
+ version = "0.20.11"
126
+ source = "registry+https://github.com/rust-lang/crates.io-index"
127
+ checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
128
+ dependencies = [
129
+ "darling_core",
130
+ "darling_macro",
131
+ ]
132
+
133
+ [[package]]
134
+ name = "darling_core"
135
+ version = "0.20.11"
136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
137
+ checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
138
+ dependencies = [
139
+ "fnv",
140
+ "ident_case",
141
+ "proc-macro2",
142
+ "quote",
143
+ "strsim",
144
+ "syn 2.0.119",
145
+ ]
146
+
147
+ [[package]]
148
+ name = "darling_macro"
149
+ version = "0.20.11"
150
+ source = "registry+https://github.com/rust-lang/crates.io-index"
151
+ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
152
+ dependencies = [
153
+ "darling_core",
154
+ "quote",
155
+ "syn 2.0.119",
156
+ ]
157
+
158
+ [[package]]
159
+ name = "dary_heap"
160
+ version = "0.3.9"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
163
+ dependencies = [
164
+ "serde",
165
+ ]
166
+
167
+ [[package]]
168
+ name = "datafog-pii"
169
+ version = "0.1.0"
170
+ dependencies = [
171
+ "anyhow",
172
+ "ort",
173
+ "serde",
174
+ "serde_json",
175
+ "tokenizers",
176
+ ]
177
+
178
+ [[package]]
179
+ name = "derive_builder"
180
+ version = "0.20.2"
181
+ source = "registry+https://github.com/rust-lang/crates.io-index"
182
+ checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
183
+ dependencies = [
184
+ "derive_builder_macro",
185
+ ]
186
+
187
+ [[package]]
188
+ name = "derive_builder_core"
189
+ version = "0.20.2"
190
+ source = "registry+https://github.com/rust-lang/crates.io-index"
191
+ checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
192
+ dependencies = [
193
+ "darling",
194
+ "proc-macro2",
195
+ "quote",
196
+ "syn 2.0.119",
197
+ ]
198
+
199
+ [[package]]
200
+ name = "derive_builder_macro"
201
+ version = "0.20.2"
202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
203
+ checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
204
+ dependencies = [
205
+ "derive_builder_core",
206
+ "syn 2.0.119",
207
+ ]
208
+
209
+ [[package]]
210
+ name = "either"
211
+ version = "1.18.0"
212
+ source = "registry+https://github.com/rust-lang/crates.io-index"
213
+ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
214
+
215
+ [[package]]
216
+ name = "esaxx-rs"
217
+ version = "0.1.10"
218
+ source = "registry+https://github.com/rust-lang/crates.io-index"
219
+ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
220
+
221
+ [[package]]
222
+ name = "find-msvc-tools"
223
+ version = "0.1.14"
224
+ source = "registry+https://github.com/rust-lang/crates.io-index"
225
+ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
226
+
227
+ [[package]]
228
+ name = "fnv"
229
+ version = "1.0.7"
230
+ source = "registry+https://github.com/rust-lang/crates.io-index"
231
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
232
+
233
+ [[package]]
234
+ name = "getrandom"
235
+ version = "0.3.4"
236
+ source = "registry+https://github.com/rust-lang/crates.io-index"
237
+ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
238
+ dependencies = [
239
+ "cfg-if",
240
+ "libc",
241
+ "r-efi",
242
+ "wasip2",
243
+ ]
244
+
245
+ [[package]]
246
+ name = "ident_case"
247
+ version = "1.0.1"
248
+ source = "registry+https://github.com/rust-lang/crates.io-index"
249
+ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
250
+
251
+ [[package]]
252
+ name = "itertools"
253
+ version = "0.14.0"
254
+ source = "registry+https://github.com/rust-lang/crates.io-index"
255
+ checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
256
+ dependencies = [
257
+ "either",
258
+ ]
259
+
260
+ [[package]]
261
+ name = "itoa"
262
+ version = "1.0.18"
263
+ source = "registry+https://github.com/rust-lang/crates.io-index"
264
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
265
+
266
+ [[package]]
267
+ name = "libc"
268
+ version = "0.2.189"
269
+ source = "registry+https://github.com/rust-lang/crates.io-index"
270
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
271
+
272
+ [[package]]
273
+ name = "libloading"
274
+ version = "0.9.0"
275
+ source = "registry+https://github.com/rust-lang/crates.io-index"
276
+ checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
277
+ dependencies = [
278
+ "cfg-if",
279
+ "windows-link",
280
+ ]
281
+
282
+ [[package]]
283
+ name = "log"
284
+ version = "0.4.34"
285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
286
+ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
287
+
288
+ [[package]]
289
+ name = "macro_rules_attribute"
290
+ version = "0.2.3"
291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
292
+ checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
293
+ dependencies = [
294
+ "macro_rules_attribute-proc_macro",
295
+ "pastey",
296
+ ]
297
+
298
+ [[package]]
299
+ name = "macro_rules_attribute-proc_macro"
300
+ version = "0.2.3"
301
+ source = "registry+https://github.com/rust-lang/crates.io-index"
302
+ checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
303
+
304
+ [[package]]
305
+ name = "matrixmultiply"
306
+ version = "0.3.11"
307
+ source = "registry+https://github.com/rust-lang/crates.io-index"
308
+ checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
309
+ dependencies = [
310
+ "autocfg",
311
+ "rawpointer",
312
+ ]
313
+
314
+ [[package]]
315
+ name = "memchr"
316
+ version = "2.8.3"
317
+ source = "registry+https://github.com/rust-lang/crates.io-index"
318
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
319
+
320
+ [[package]]
321
+ name = "minimal-lexical"
322
+ version = "0.2.1"
323
+ source = "registry+https://github.com/rust-lang/crates.io-index"
324
+ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
325
+
326
+ [[package]]
327
+ name = "monostate"
328
+ version = "0.1.18"
329
+ source = "registry+https://github.com/rust-lang/crates.io-index"
330
+ checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
331
+ dependencies = [
332
+ "monostate-impl",
333
+ "serde",
334
+ "serde_core",
335
+ ]
336
+
337
+ [[package]]
338
+ name = "monostate-impl"
339
+ version = "0.1.18"
340
+ source = "registry+https://github.com/rust-lang/crates.io-index"
341
+ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
342
+ dependencies = [
343
+ "proc-macro2",
344
+ "quote",
345
+ "syn 2.0.119",
346
+ ]
347
+
348
+ [[package]]
349
+ name = "ndarray"
350
+ version = "0.17.2"
351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
352
+ checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
353
+ dependencies = [
354
+ "matrixmultiply",
355
+ "num-complex",
356
+ "num-integer",
357
+ "num-traits",
358
+ "portable-atomic",
359
+ "portable-atomic-util",
360
+ "rawpointer",
361
+ ]
362
+
363
+ [[package]]
364
+ name = "nom"
365
+ version = "7.1.3"
366
+ source = "registry+https://github.com/rust-lang/crates.io-index"
367
+ checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
368
+ dependencies = [
369
+ "memchr",
370
+ "minimal-lexical",
371
+ ]
372
+
373
+ [[package]]
374
+ name = "num-complex"
375
+ version = "0.4.6"
376
+ source = "registry+https://github.com/rust-lang/crates.io-index"
377
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
378
+ dependencies = [
379
+ "num-traits",
380
+ ]
381
+
382
+ [[package]]
383
+ name = "num-integer"
384
+ version = "0.1.47"
385
+ source = "registry+https://github.com/rust-lang/crates.io-index"
386
+ checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
387
+ dependencies = [
388
+ "num-traits",
389
+ ]
390
+
391
+ [[package]]
392
+ name = "num-traits"
393
+ version = "0.2.19"
394
+ source = "registry+https://github.com/rust-lang/crates.io-index"
395
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
396
+ dependencies = [
397
+ "autocfg",
398
+ ]
399
+
400
+ [[package]]
401
+ name = "once_cell"
402
+ version = "1.21.4"
403
+ source = "registry+https://github.com/rust-lang/crates.io-index"
404
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
405
+
406
+ [[package]]
407
+ name = "onig"
408
+ version = "6.5.3"
409
+ source = "registry+https://github.com/rust-lang/crates.io-index"
410
+ checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
411
+ dependencies = [
412
+ "bitflags",
413
+ "libc",
414
+ "once_cell",
415
+ "onig_sys",
416
+ ]
417
+
418
+ [[package]]
419
+ name = "onig_sys"
420
+ version = "69.9.3"
421
+ source = "registry+https://github.com/rust-lang/crates.io-index"
422
+ checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
423
+ dependencies = [
424
+ "cc",
425
+ "pkg-config",
426
+ ]
427
+
428
+ [[package]]
429
+ name = "ort"
430
+ version = "2.0.0-rc.13"
431
+ source = "registry+https://github.com/rust-lang/crates.io-index"
432
+ checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b"
433
+ dependencies = [
434
+ "libloading",
435
+ "ndarray",
436
+ "ort-sys",
437
+ "smallvec",
438
+ "tracing",
439
+ ]
440
+
441
+ [[package]]
442
+ name = "ort-sys"
443
+ version = "2.0.0-rc.13"
444
+ source = "registry+https://github.com/rust-lang/crates.io-index"
445
+ checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872"
446
+
447
+ [[package]]
448
+ name = "paste"
449
+ version = "1.0.15"
450
+ source = "registry+https://github.com/rust-lang/crates.io-index"
451
+ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
452
+
453
+ [[package]]
454
+ name = "pastey"
455
+ version = "0.2.3"
456
+ source = "registry+https://github.com/rust-lang/crates.io-index"
457
+ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
458
+
459
+ [[package]]
460
+ name = "pin-project-lite"
461
+ version = "0.2.17"
462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
463
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
464
+
465
+ [[package]]
466
+ name = "pkg-config"
467
+ version = "0.3.34"
468
+ source = "registry+https://github.com/rust-lang/crates.io-index"
469
+ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
470
+
471
+ [[package]]
472
+ name = "portable-atomic"
473
+ version = "1.15.0"
474
+ source = "registry+https://github.com/rust-lang/crates.io-index"
475
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
476
+
477
+ [[package]]
478
+ name = "portable-atomic-util"
479
+ version = "0.2.8"
480
+ source = "registry+https://github.com/rust-lang/crates.io-index"
481
+ checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
482
+ dependencies = [
483
+ "portable-atomic",
484
+ ]
485
+
486
+ [[package]]
487
+ name = "ppv-lite86"
488
+ version = "0.2.21"
489
+ source = "registry+https://github.com/rust-lang/crates.io-index"
490
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
491
+ dependencies = [
492
+ "zerocopy",
493
+ ]
494
+
495
+ [[package]]
496
+ name = "proc-macro2"
497
+ version = "1.0.107"
498
+ source = "registry+https://github.com/rust-lang/crates.io-index"
499
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
500
+ dependencies = [
501
+ "unicode-ident",
502
+ ]
503
+
504
+ [[package]]
505
+ name = "quote"
506
+ version = "1.0.47"
507
+ source = "registry+https://github.com/rust-lang/crates.io-index"
508
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
509
+ dependencies = [
510
+ "proc-macro2",
511
+ ]
512
+
513
+ [[package]]
514
+ name = "r-efi"
515
+ version = "5.3.0"
516
+ source = "registry+https://github.com/rust-lang/crates.io-index"
517
+ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
518
+
519
+ [[package]]
520
+ name = "rand"
521
+ version = "0.9.5"
522
+ source = "registry+https://github.com/rust-lang/crates.io-index"
523
+ checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
524
+ dependencies = [
525
+ "rand_chacha",
526
+ "rand_core",
527
+ ]
528
+
529
+ [[package]]
530
+ name = "rand_chacha"
531
+ version = "0.9.0"
532
+ source = "registry+https://github.com/rust-lang/crates.io-index"
533
+ checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
534
+ dependencies = [
535
+ "ppv-lite86",
536
+ "rand_core",
537
+ ]
538
+
539
+ [[package]]
540
+ name = "rand_core"
541
+ version = "0.9.5"
542
+ source = "registry+https://github.com/rust-lang/crates.io-index"
543
+ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
544
+ dependencies = [
545
+ "getrandom",
546
+ ]
547
+
548
+ [[package]]
549
+ name = "rawpointer"
550
+ version = "0.2.1"
551
+ source = "registry+https://github.com/rust-lang/crates.io-index"
552
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
553
+
554
+ [[package]]
555
+ name = "rayon"
556
+ version = "1.12.0"
557
+ source = "registry+https://github.com/rust-lang/crates.io-index"
558
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
559
+ dependencies = [
560
+ "either",
561
+ "rayon-core",
562
+ ]
563
+
564
+ [[package]]
565
+ name = "rayon-cond"
566
+ version = "0.4.0"
567
+ source = "registry+https://github.com/rust-lang/crates.io-index"
568
+ checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
569
+ dependencies = [
570
+ "either",
571
+ "itertools",
572
+ "rayon",
573
+ ]
574
+
575
+ [[package]]
576
+ name = "rayon-core"
577
+ version = "1.13.0"
578
+ source = "registry+https://github.com/rust-lang/crates.io-index"
579
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
580
+ dependencies = [
581
+ "crossbeam-deque",
582
+ "crossbeam-utils",
583
+ ]
584
+
585
+ [[package]]
586
+ name = "regex"
587
+ version = "1.13.1"
588
+ source = "registry+https://github.com/rust-lang/crates.io-index"
589
+ checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
590
+ dependencies = [
591
+ "aho-corasick",
592
+ "memchr",
593
+ "regex-automata",
594
+ "regex-syntax",
595
+ ]
596
+
597
+ [[package]]
598
+ name = "regex-automata"
599
+ version = "0.4.18"
600
+ source = "registry+https://github.com/rust-lang/crates.io-index"
601
+ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
602
+ dependencies = [
603
+ "aho-corasick",
604
+ "memchr",
605
+ "regex-syntax",
606
+ ]
607
+
608
+ [[package]]
609
+ name = "regex-syntax"
610
+ version = "0.8.11"
611
+ source = "registry+https://github.com/rust-lang/crates.io-index"
612
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
613
+
614
+ [[package]]
615
+ name = "rustversion"
616
+ version = "1.0.23"
617
+ source = "registry+https://github.com/rust-lang/crates.io-index"
618
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
619
+
620
+ [[package]]
621
+ name = "ryu"
622
+ version = "1.0.23"
623
+ source = "registry+https://github.com/rust-lang/crates.io-index"
624
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
625
+
626
+ [[package]]
627
+ name = "serde"
628
+ version = "1.0.229"
629
+ source = "registry+https://github.com/rust-lang/crates.io-index"
630
+ checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
631
+ dependencies = [
632
+ "serde_core",
633
+ "serde_derive",
634
+ ]
635
+
636
+ [[package]]
637
+ name = "serde_core"
638
+ version = "1.0.229"
639
+ source = "registry+https://github.com/rust-lang/crates.io-index"
640
+ checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
641
+ dependencies = [
642
+ "serde_derive",
643
+ ]
644
+
645
+ [[package]]
646
+ name = "serde_derive"
647
+ version = "1.0.229"
648
+ source = "registry+https://github.com/rust-lang/crates.io-index"
649
+ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
650
+ dependencies = [
651
+ "proc-macro2",
652
+ "quote",
653
+ "syn 3.0.6",
654
+ ]
655
+
656
+ [[package]]
657
+ name = "serde_json"
658
+ version = "1.0.151"
659
+ source = "registry+https://github.com/rust-lang/crates.io-index"
660
+ checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
661
+ dependencies = [
662
+ "itoa",
663
+ "memchr",
664
+ "serde",
665
+ "serde_core",
666
+ "zmij",
667
+ ]
668
+
669
+ [[package]]
670
+ name = "shlex"
671
+ version = "2.0.1"
672
+ source = "registry+https://github.com/rust-lang/crates.io-index"
673
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
674
+
675
+ [[package]]
676
+ name = "smallvec"
677
+ version = "1.16.2"
678
+ source = "registry+https://github.com/rust-lang/crates.io-index"
679
+ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
680
+
681
+ [[package]]
682
+ name = "spm_precompiled"
683
+ version = "0.1.4"
684
+ source = "registry+https://github.com/rust-lang/crates.io-index"
685
+ checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
686
+ dependencies = [
687
+ "base64",
688
+ "nom",
689
+ "serde",
690
+ "unicode-segmentation",
691
+ ]
692
+
693
+ [[package]]
694
+ name = "static_assertions"
695
+ version = "1.1.0"
696
+ source = "registry+https://github.com/rust-lang/crates.io-index"
697
+ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
698
+
699
+ [[package]]
700
+ name = "strsim"
701
+ version = "0.11.1"
702
+ source = "registry+https://github.com/rust-lang/crates.io-index"
703
+ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
704
+
705
+ [[package]]
706
+ name = "syn"
707
+ version = "2.0.119"
708
+ source = "registry+https://github.com/rust-lang/crates.io-index"
709
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
710
+ dependencies = [
711
+ "proc-macro2",
712
+ "quote",
713
+ "unicode-ident",
714
+ ]
715
+
716
+ [[package]]
717
+ name = "syn"
718
+ version = "3.0.6"
719
+ source = "registry+https://github.com/rust-lang/crates.io-index"
720
+ checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
721
+ dependencies = [
722
+ "proc-macro2",
723
+ "quote",
724
+ "unicode-ident",
725
+ ]
726
+
727
+ [[package]]
728
+ name = "thiserror"
729
+ version = "2.0.21"
730
+ source = "registry+https://github.com/rust-lang/crates.io-index"
731
+ checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
732
+ dependencies = [
733
+ "thiserror-impl",
734
+ ]
735
+
736
+ [[package]]
737
+ name = "thiserror-impl"
738
+ version = "2.0.21"
739
+ source = "registry+https://github.com/rust-lang/crates.io-index"
740
+ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
741
+ dependencies = [
742
+ "proc-macro2",
743
+ "quote",
744
+ "syn 3.0.6",
745
+ ]
746
+
747
+ [[package]]
748
+ name = "tokenizers"
749
+ version = "0.23.2"
750
+ source = "registry+https://github.com/rust-lang/crates.io-index"
751
+ checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc"
752
+ dependencies = [
753
+ "ahash",
754
+ "compact_str",
755
+ "daachorse",
756
+ "dary_heap",
757
+ "derive_builder",
758
+ "esaxx-rs",
759
+ "getrandom",
760
+ "itertools",
761
+ "log",
762
+ "macro_rules_attribute",
763
+ "monostate",
764
+ "onig",
765
+ "paste",
766
+ "rand",
767
+ "rayon",
768
+ "rayon-cond",
769
+ "regex",
770
+ "regex-syntax",
771
+ "serde",
772
+ "serde_json",
773
+ "spm_precompiled",
774
+ "thiserror",
775
+ "unicode-normalization-alignments",
776
+ "unicode-segmentation",
777
+ "unicode_categories",
778
+ ]
779
+
780
+ [[package]]
781
+ name = "tracing"
782
+ version = "0.1.44"
783
+ source = "registry+https://github.com/rust-lang/crates.io-index"
784
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
785
+ dependencies = [
786
+ "pin-project-lite",
787
+ "tracing-core",
788
+ ]
789
+
790
+ [[package]]
791
+ name = "tracing-core"
792
+ version = "0.1.36"
793
+ source = "registry+https://github.com/rust-lang/crates.io-index"
794
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
795
+ dependencies = [
796
+ "once_cell",
797
+ ]
798
+
799
+ [[package]]
800
+ name = "unicode-ident"
801
+ version = "1.0.26"
802
+ source = "registry+https://github.com/rust-lang/crates.io-index"
803
+ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
804
+
805
+ [[package]]
806
+ name = "unicode-normalization-alignments"
807
+ version = "0.1.12"
808
+ source = "registry+https://github.com/rust-lang/crates.io-index"
809
+ checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
810
+ dependencies = [
811
+ "smallvec",
812
+ ]
813
+
814
+ [[package]]
815
+ name = "unicode-segmentation"
816
+ version = "1.13.3"
817
+ source = "registry+https://github.com/rust-lang/crates.io-index"
818
+ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
819
+
820
+ [[package]]
821
+ name = "unicode_categories"
822
+ version = "0.1.1"
823
+ source = "registry+https://github.com/rust-lang/crates.io-index"
824
+ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
825
+
826
+ [[package]]
827
+ name = "version_check"
828
+ version = "0.9.5"
829
+ source = "registry+https://github.com/rust-lang/crates.io-index"
830
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
831
+
832
+ [[package]]
833
+ name = "wasip2"
834
+ version = "1.0.4+wasi-0.2.12"
835
+ source = "registry+https://github.com/rust-lang/crates.io-index"
836
+ checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
837
+ dependencies = [
838
+ "wit-bindgen",
839
+ ]
840
+
841
+ [[package]]
842
+ name = "windows-link"
843
+ version = "0.2.1"
844
+ source = "registry+https://github.com/rust-lang/crates.io-index"
845
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
846
+
847
+ [[package]]
848
+ name = "wit-bindgen"
849
+ version = "0.57.1"
850
+ source = "registry+https://github.com/rust-lang/crates.io-index"
851
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
852
+
853
+ [[package]]
854
+ name = "zerocopy"
855
+ version = "0.8.59"
856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
857
+ checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
858
+ dependencies = [
859
+ "zerocopy-derive",
860
+ ]
861
+
862
+ [[package]]
863
+ name = "zerocopy-derive"
864
+ version = "0.8.59"
865
+ source = "registry+https://github.com/rust-lang/crates.io-index"
866
+ checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
867
+ dependencies = [
868
+ "proc-macro2",
869
+ "quote",
870
+ "syn 2.0.119",
871
+ ]
872
+
873
+ [[package]]
874
+ name = "zmij"
875
+ version = "1.0.23"
876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
877
+ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
runtime/Cargo.toml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "datafog-pii"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ publish = false
6
+
7
+ [dependencies]
8
+ anyhow = "1"
9
+ serde = { version = "1", features = ["derive"] }
10
+ serde_json = "1"
11
+ tokenizers = { version = "=0.23.2", default-features = false, features = ["onig"] }
12
+ ort = { version = "=2.0.0-rc.13", default-features = false, features = ["std", "load-dynamic", "api-27"] }
runtime/licenses/onnxruntime-LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
runtime/licenses/onnxruntime-ThirdPartyNotices.txt ADDED
The diff for this file is too large to render. See raw diff
 
runtime/licenses/ort-LICENSE-APACHE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
runtime/licenses/ort-LICENSE-MIT ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023-2026 pyke.io
4
+ Copyright (c) 2020 Nicolas Bigaouette
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
runtime/licenses/tokenizers-LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
runtime/src/main.rs ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use anyhow::{Context, Result, bail, ensure};
2
+ use ort::{session::Session, value::Tensor};
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::json;
5
+ use std::{
6
+ io::{self, BufRead, Write},
7
+ path::Path,
8
+ };
9
+ use tokenizers::{Encoding, Tokenizer};
10
+
11
+ #[derive(Deserialize)]
12
+ struct Config {
13
+ labels: Vec<String>,
14
+ max_length: usize,
15
+ stride: usize,
16
+ #[serde(default)]
17
+ calibration: Option<Calibration>,
18
+ #[serde(default)]
19
+ calibration_file: Option<String>,
20
+ }
21
+ #[derive(Deserialize)]
22
+ struct Calibration {
23
+ #[serde(alias = "xs")]
24
+ x: Vec<f64>,
25
+ #[serde(alias = "ys")]
26
+ y: Vec<f64>,
27
+ threshold: Option<f64>,
28
+ }
29
+ impl Calibration {
30
+ fn apply(&self, v: f64) -> f64 {
31
+ let i = self.x.partition_point(|x| *x < v);
32
+ if i == 0 {
33
+ self.y[0]
34
+ } else if i == self.x.len() {
35
+ *self.y.last().unwrap()
36
+ } else {
37
+ self.y[i - 1]
38
+ + (v - self.x[i - 1]) / (self.x[i] - self.x[i - 1]) * (self.y[i] - self.y[i - 1])
39
+ }
40
+ }
41
+ }
42
+ #[derive(Clone, Debug, Serialize)]
43
+ struct Finding {
44
+ label: String,
45
+ start: usize,
46
+ end: usize,
47
+ raw_score: f64,
48
+ confidence: Option<f64>,
49
+ #[serde(skip)]
50
+ boundary_fragment: bool,
51
+ }
52
+ fn encode_windows(
53
+ tokenizer: &Tokenizer,
54
+ text: &str,
55
+ max_length: usize,
56
+ stride: usize,
57
+ ) -> Result<Vec<Encoding>> {
58
+ let full = tokenizer
59
+ .encode(text, false)
60
+ .map_err(|e| anyhow::anyhow!("{e}"))?;
61
+ let capacity = max_length - 2;
62
+ ensure!(stride < capacity, "invalid stride");
63
+ let cls = tokenizer
64
+ .token_to_id("[CLS]")
65
+ .context("missing CLS token")?;
66
+ let sep = tokenizer
67
+ .token_to_id("[SEP]")
68
+ .context("missing SEP token")?;
69
+ let mut windows = Vec::new();
70
+ let mut start = 0;
71
+ loop {
72
+ let end = (start + capacity).min(full.len());
73
+ let n = end - start + 2;
74
+ let mut ids = vec![cls];
75
+ ids.extend_from_slice(&full.get_ids()[start..end]);
76
+ ids.push(sep);
77
+ let mut offsets = vec![(0, 0)];
78
+ offsets.extend_from_slice(&full.get_offsets()[start..end]);
79
+ offsets.push((0, 0));
80
+ let mut tokens = vec!["[CLS]".to_string()];
81
+ tokens.extend_from_slice(&full.get_tokens()[start..end]);
82
+ tokens.push("[SEP]".to_string());
83
+ let mut special = vec![0; n];
84
+ special[0] = 1;
85
+ special[n - 1] = 1;
86
+ windows.push(Encoding::new(
87
+ ids,
88
+ vec![0; n],
89
+ tokens,
90
+ vec![None; n],
91
+ offsets,
92
+ special,
93
+ vec![1; n],
94
+ vec![],
95
+ Default::default(),
96
+ ));
97
+ if end == full.len() {
98
+ break;
99
+ }
100
+ start += capacity - stride;
101
+ }
102
+ Ok(windows)
103
+ }
104
+ fn decode(enc: &tokenizers::Encoding, logits: &[f32], labels: &[String]) -> Vec<Finding> {
105
+ let k = 1 + 2 * labels.len();
106
+ let mut out = Vec::new();
107
+ let mut current: Option<Finding> = None;
108
+ for (i, row) in logits.chunks_exact(k).enumerate() {
109
+ let (start, end) = enc.get_offsets()[i];
110
+ if enc.get_special_tokens_mask()[i] != 0 || enc.get_attention_mask()[i] == 0 || start == end
111
+ {
112
+ if let Some(f) = current.take() {
113
+ out.push(f);
114
+ }
115
+ continue;
116
+ }
117
+ let mut id = 0;
118
+ for j in 1..k {
119
+ if row[j] > row[id] {
120
+ id = j;
121
+ }
122
+ }
123
+ if id == 0 {
124
+ if let Some(f) = current.take() {
125
+ out.push(f);
126
+ }
127
+ continue;
128
+ }
129
+ let label = &labels[(id - 1) / 2];
130
+ let max = row[id] as f64;
131
+ let prob = 1.0 / row.iter().map(|v| (*v as f64 - max).exp()).sum::<f64>();
132
+ if id % 2 == 0 && current.as_ref().is_some_and(|f| f.label == *label) {
133
+ let f = current.as_mut().unwrap();
134
+ f.end = end;
135
+ f.raw_score = f.raw_score.min(prob);
136
+ } else {
137
+ if let Some(f) = current.take() {
138
+ out.push(f);
139
+ }
140
+ current = Some(Finding {
141
+ label: label.clone(),
142
+ start,
143
+ end,
144
+ raw_score: prob,
145
+ confidence: None,
146
+ boundary_fragment: false,
147
+ });
148
+ }
149
+ }
150
+ if let Some(f) = current {
151
+ out.push(f);
152
+ }
153
+ out
154
+ }
155
+ fn mark_boundaries(
156
+ enc: &tokenizers::Encoding,
157
+ findings: &mut [Finding],
158
+ has_previous: bool,
159
+ has_next: bool,
160
+ ) {
161
+ let content: Vec<_> = enc.get_offsets().iter().filter(|(a, b)| a != b).collect();
162
+ if let (Some(first), Some(last)) = (content.first(), content.last()) {
163
+ for f in findings {
164
+ f.boundary_fragment =
165
+ (has_previous && f.start == first.0) || (has_next && f.end == last.1);
166
+ }
167
+ }
168
+ }
169
+ fn resolve(findings: Vec<Finding>) -> Vec<Finding> {
170
+ let mut findings: Vec<Finding> = findings
171
+ .iter()
172
+ .filter(|f| {
173
+ !(f.boundary_fragment
174
+ && findings.iter().any(|g| {
175
+ g.label == f.label
176
+ && g.start <= f.start
177
+ && g.end >= f.end
178
+ && (g.start < f.start || g.end > f.end)
179
+ }))
180
+ })
181
+ .cloned()
182
+ .collect();
183
+ findings.sort_by(|a, b| {
184
+ b.raw_score
185
+ .total_cmp(&a.raw_score)
186
+ .then(a.start.cmp(&b.start))
187
+ .then(a.end.cmp(&b.end))
188
+ .then(a.label.cmp(&b.label))
189
+ });
190
+ let mut accepted: Vec<Finding> = Vec::new();
191
+ for f in findings {
192
+ if !accepted.iter().any(|a| f.start < a.end && a.start < f.end) {
193
+ accepted.push(f);
194
+ }
195
+ }
196
+ accepted.sort_by_key(|f| (f.start, f.end));
197
+ accepted
198
+ }
199
+ fn redact(text: &str, findings: &[Finding]) -> Result<String> {
200
+ let mut s = String::new();
201
+ let mut pos = 0;
202
+ for f in findings {
203
+ ensure!(
204
+ f.start >= pos
205
+ && f.end <= text.len()
206
+ && text.is_char_boundary(f.start)
207
+ && text.is_char_boundary(f.end),
208
+ "invalid UTF-8 finding boundary"
209
+ );
210
+ s.push_str(&text[pos..f.start]);
211
+ s.push_str("[REDACTED]");
212
+ pos = f.end;
213
+ }
214
+ s.push_str(&text[pos..]);
215
+ Ok(s)
216
+ }
217
+ struct Detector {
218
+ config: Config,
219
+ tokenizer: Tokenizer,
220
+ session: Session,
221
+ }
222
+ impl Detector {
223
+ fn load(path: &Path) -> Result<Self> {
224
+ let mut config: Config = serde_json::from_slice(&std::fs::read(path.join("config.json"))?)?;
225
+ if let Some(file) = &config.calibration_file {
226
+ config.calibration = Some(serde_json::from_slice(&std::fs::read(path.join(file))?)?);
227
+ }
228
+ ensure!(config.labels.len() == 7, "expected seven labels");
229
+ ensure!(
230
+ config.max_length > 2 && config.stride < config.max_length - 2,
231
+ "invalid chunk settings"
232
+ );
233
+ if let Some(c) = &config.calibration {
234
+ ensure!(
235
+ !c.x.is_empty() && c.x.len() == c.y.len(),
236
+ "invalid calibration lengths"
237
+ );
238
+ ensure!(
239
+ c.x.iter().all(|x| x.is_finite())
240
+ && c.y.iter().all(|y| y.is_finite() && (0.0..=1.0).contains(y))
241
+ && c.x.windows(2).all(|v| v[0] < v[1])
242
+ && c.y.windows(2).all(|v| v[0] <= v[1]),
243
+ "invalid calibration values"
244
+ );
245
+ }
246
+ let mut tokenizer = Tokenizer::from_file(path.join("tokenizer.json"))
247
+ .map_err(|e| anyhow::anyhow!("{e}"))?;
248
+ tokenizer.with_padding(None);
249
+ tokenizer
250
+ .with_truncation(None)
251
+ .map_err(|e| anyhow::anyhow!("{e}"))?;
252
+ let _ = ort::init_from(path.join(if cfg!(target_os = "windows") { "onnxruntime.dll" } else if cfg!(target_os = "macos") { "libonnxruntime.dylib" } else { "libonnxruntime.so" }))?.commit();
253
+ let session = Session::builder()?
254
+ .with_intra_threads(1)
255
+ .map_err(|e| anyhow::anyhow!("{e}"))?
256
+ .commit_from_file(path.join("model.onnx"))?;
257
+ Ok(Self {
258
+ config,
259
+ tokenizer,
260
+ session,
261
+ })
262
+ }
263
+ fn predict(&mut self, text: &str, debug: bool) -> Result<serde_json::Value> {
264
+ let encodings = encode_windows(
265
+ &self.tokenizer,
266
+ text,
267
+ self.config.max_length,
268
+ self.config.stride,
269
+ )?;
270
+ let mut findings = Vec::new();
271
+ let mut windows = Vec::new();
272
+ for (index, e) in encodings.iter().enumerate() {
273
+ let n = e.len();
274
+ let ids: Vec<i64> = e.get_ids().iter().map(|x| *x as i64).collect();
275
+ let mask: Vec<i64> = e.get_attention_mask().iter().map(|x| *x as i64).collect();
276
+ let outputs=self.session.run(ort::inputs!["input_ids"=>Tensor::from_array(([1,n],ids.clone()))?,"attention_mask"=>Tensor::from_array(([1,n],mask.clone()))?])?;
277
+ let (shape, logits) = outputs["logits"].try_extract_tensor::<f32>()?;
278
+ ensure!(
279
+ shape.as_ref() == [1, n as i64, 15],
280
+ "unexpected logits shape: {shape:?}"
281
+ );
282
+ ensure!(logits.iter().all(|x| x.is_finite()), "non-finite logits");
283
+ let mut decoded = decode(e, logits, &self.config.labels);
284
+ mark_boundaries(e, &mut decoded, index > 0, index + 1 < encodings.len());
285
+ if n == self.config.max_length {
286
+ let content: Vec<_> = e.get_offsets().iter().filter(|(a, b)| a != b).collect();
287
+ if let (Some(first), Some(last)) = (content.first(), content.last()) {
288
+ ensure!(
289
+ !decoded
290
+ .iter()
291
+ .any(|f| f.start == first.0 && f.end == last.1),
292
+ "entity spans a full window; complete entity boundary is unsupported"
293
+ );
294
+ }
295
+ }
296
+ findings.extend(decoded);
297
+ if debug {
298
+ windows.push(json!({"input_ids":ids,"attention_mask":mask,"offsets":e.get_offsets(),"special_tokens_mask":e.get_special_tokens_mask(),"logits":logits.chunks_exact(15).collect::<Vec<_>>()}));
299
+ }
300
+ }
301
+ let mut findings = resolve(findings);
302
+ if let Some(c) = &self.config.calibration {
303
+ for f in &mut findings {
304
+ f.confidence = Some(c.apply(f.raw_score));
305
+ }
306
+ if let Some(t) = c.threshold {
307
+ findings.retain(|f| f.confidence.unwrap() >= t);
308
+ }
309
+ }
310
+ let redacted = redact(text, &findings)?;
311
+ let mut result =
312
+ json!({"findings":findings,"contains_pii":!findings.is_empty(),"redacted":redacted});
313
+ if debug {
314
+ result["windows"] = json!(windows);
315
+ }
316
+ Ok(result)
317
+ }
318
+ }
319
+ fn main() -> Result<()> {
320
+ let args: Vec<String> = std::env::args().skip(1).collect();
321
+ if args.is_empty() {
322
+ bail!("usage: datafog-pii BUNDLE [--debug]; stdin JSONL objects with text:string");
323
+ }
324
+ ensure!(
325
+ args.len() == 1 || (args.len() == 2 && args[1] == "--debug"),
326
+ "unknown arguments"
327
+ );
328
+ let mut detector = Detector::load(Path::new(&args[0])).context("loading detector")?;
329
+ let debug = args.len() == 2;
330
+ let stdin = io::stdin();
331
+ let mut stdout = io::BufWriter::new(io::stdout().lock());
332
+ for line in stdin.lock().lines() {
333
+ let line = line?;
334
+ let result = (|| -> Result<_> {
335
+ let input: serde_json::Value = serde_json::from_str(&line)?;
336
+ let text = input
337
+ .get("text")
338
+ .and_then(|t| t.as_str())
339
+ .context("expected text:string")?;
340
+ detector.predict(text, debug)
341
+ })();
342
+ let output = match result {
343
+ Ok(v) => v,
344
+ Err(e) => json!({"error":format!("{e:#}")}),
345
+ };
346
+ writeln!(stdout, "{}", serde_json::to_string(&output)?)?;
347
+ stdout.flush()?;
348
+ }
349
+ Ok(())
350
+ }
351
+ #[cfg(test)]
352
+ mod tests {
353
+ use super::*;
354
+ #[test]
355
+ fn unicode_overlap_and_adjacent() {
356
+ let f = |start, end, score| Finding {
357
+ label: "first_name".into(),
358
+ start,
359
+ end,
360
+ raw_score: score,
361
+ confidence: None,
362
+ boundary_fragment: false,
363
+ };
364
+ let spans = resolve(vec![f(5, 8, 0.6), f(5, 6, 0.9), f(6, 8, 0.8), f(5, 6, 0.7)]);
365
+ assert_eq!(spans.len(), 2);
366
+ assert_eq!(
367
+ redact("🙂 José", &spans).unwrap(),
368
+ "🙂 [REDACTED][REDACTED]é"
369
+ );
370
+ assert!(redact("🙂", &[f(1, 3, 1.)]).is_err());
371
+ }
372
+ #[test]
373
+ fn oracle_boundaries_and_complete_token_coverage() {
374
+ let fixture: serde_json::Value =
375
+ serde_json::from_str(include_str!("../tests/boundary-oracle.json")).unwrap();
376
+ let mut tokenizer = Tokenizer::from_file(
377
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../bundle/tokenizer.json"),
378
+ )
379
+ .unwrap();
380
+ tokenizer.with_truncation(None).unwrap();
381
+ tokenizer.with_padding(None);
382
+ let labels: Vec<String> = serde_json::from_value(fixture["labels"].clone()).unwrap();
383
+ for case in fixture["cases"].as_array().unwrap() {
384
+ let text = case["text"].as_str().unwrap();
385
+ let windows = encode_windows(&tokenizer, text, 512, 42).unwrap();
386
+ assert_eq!(windows.len(), case["windows"].as_array().unwrap().len());
387
+ let mut findings = Vec::new();
388
+ let mut covered = Vec::new();
389
+ for (i, e) in windows.iter().enumerate() {
390
+ let oracle = &case["windows"][i];
391
+ assert_eq!(
392
+ serde_json::to_value(e.get_ids()).unwrap(),
393
+ oracle["input_ids"]
394
+ );
395
+ assert_eq!(
396
+ serde_json::to_value(e.get_offsets()).unwrap(),
397
+ oracle["offsets"]
398
+ );
399
+ covered.extend_from_slice(&e.get_ids()[if i == 0 { 1 } else { 43 }..e.len() - 1]);
400
+ let logits: Vec<Vec<f32>> =
401
+ serde_json::from_value(oracle["logits"].clone()).unwrap();
402
+ let mut f = decode(e, &logits.concat(), &labels);
403
+ mark_boundaries(e, &mut f, i > 0, i + 1 < windows.len());
404
+ findings.extend(f);
405
+ }
406
+ let full = tokenizer.encode(text, false).unwrap();
407
+ assert_eq!(covered, full.get_ids());
408
+ let actual = resolve(findings);
409
+ let spans: Vec<_> = actual
410
+ .iter()
411
+ .map(|f| json!({"label":f.label,"start":f.start,"end":f.end}))
412
+ .collect();
413
+ assert_eq!(json!(spans), case["expected"]);
414
+ assert_eq!(
415
+ redact(text, &actual).unwrap(),
416
+ case["redacted"].as_str().unwrap()
417
+ );
418
+ }
419
+ }
420
+ #[test]
421
+ fn interpolation() {
422
+ let c = Calibration {
423
+ x: vec![0.2, 0.8],
424
+ y: vec![0.1, 0.9],
425
+ threshold: None,
426
+ };
427
+ assert!((c.apply(0.5) - 0.5).abs() < 1e-12);
428
+ assert_eq!(c.apply(0.), 0.1);
429
+ assert_eq!(c.apply(1.), 0.9);
430
+ }
431
+ }
runtime/tests/boundary-oracle.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": false,
5
+ "is_local": true,
6
+ "local_files_only": true,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }
tools/install_bundle.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Install a verified, already-downloaded snapshot; inference stays offline."""
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import platform
7
+ import shutil
8
+ import tarfile
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def digest(path):
14
+ with path.open('rb') as stream:
15
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
16
+
17
+
18
+ def verify(root):
19
+ manifest = json.loads((root / 'manifest.json').read_text())
20
+ for name, metadata in manifest['files'].items():
21
+ path = root / name
22
+ if Path(name).is_absolute() or '..' in Path(name).parts:
23
+ raise ValueError('Unsafe manifest path')
24
+ if not path.is_file() or path.stat().st_size != metadata['bytes'] or digest(path) != metadata['sha256']:
25
+ raise ValueError(f'Integrity check failed: {name}')
26
+ return manifest
27
+
28
+
29
+ def install(snapshot, destination):
30
+ verify(snapshot)
31
+ if platform.system() != 'Darwin' or platform.machine() not in ('arm64', 'aarch64'):
32
+ raise ValueError('This release archive is qualified only for macOS ARM64; see PLATFORMS.md')
33
+ version = platform.mac_ver()[0]
34
+ if version and tuple(map(int, version.split('.')[:2])) < (14, 0):
35
+ raise ValueError('The preserved dylib requires macOS 14.0 or newer')
36
+ if destination.exists():
37
+ raise ValueError('Destination already exists; select a new directory')
38
+ destination.parent.mkdir(parents=True, exist_ok=True)
39
+ with tempfile.TemporaryDirectory(dir=destination.parent) as temporary:
40
+ stage = Path(temporary) / 'bundle'
41
+ stage.mkdir()
42
+ with tarfile.open(snapshot / 'archives/datafog-pii-v0.1.0-macos-arm64.tar.gz') as archive:
43
+ for member in archive.getmembers():
44
+ if member.name.startswith('/') or '..' in Path(member.name).parts or not (member.isfile() or member.isdir()):
45
+ raise ValueError('Unsafe archive member')
46
+ archive.extractall(stage, filter='data')
47
+ (stage / 'datafog-pii').chmod(0o755)
48
+ # Validate extracted content independently, including executable and notices.
49
+ checksums = json.loads((stage / 'checksums.json').read_text())
50
+ for name, expected in checksums.items():
51
+ if digest(stage / name) != expected:
52
+ raise ValueError(f'Extracted bundle checksum failed: {name}')
53
+ stage.rename(destination)
54
+ return destination
55
+
56
+
57
+ if __name__ == '__main__':
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument('--snapshot', required=True, type=Path)
60
+ parser.add_argument('--destination', required=True, type=Path)
61
+ args = parser.parse_args()
62
+ print(install(args.snapshot.resolve(), args.destination.resolve()))