vicary 0.2.18 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/assets/pools/given.txt +181 -0
- data/assets/pools/org.txt +20 -0
- data/assets/pools/place.txt +453 -0
- data/assets/pools/school.txt +26 -0
- data/assets/pools/street.txt +27 -0
- data/assets/pools/surname.txt +291 -0
- data/lib/vicary/adlib.rb +1046 -0
- data/lib/vicary/candidates.rb +30 -0
- data/lib/vicary/redact.rb +15 -0
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +1 -0
- metadata +9 -2
data/lib/vicary/adlib.rb
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Vicary
|
|
8
|
+
# Every pool entry is taken or collides with the document.
|
|
9
|
+
class AdlibPoolExhausted < RuntimeError; end
|
|
10
|
+
|
|
11
|
+
# Adlib: an invented value stands in for every redacted span — the Ruby port of
|
|
12
|
+
# `python/src/vicary/adlib.py`. README "Adlib" is the byte-level spec;
|
|
13
|
+
# `conformance/adlib.json` holds the reference's answers. Never log the map.
|
|
14
|
+
module Adlib
|
|
15
|
+
GIVEN = "given"
|
|
16
|
+
SURNAME = "surname"
|
|
17
|
+
NAME = "NAME"
|
|
18
|
+
SCHOOL = "SCHOOL"
|
|
19
|
+
ORGANIZATION = "ORGANIZATION"
|
|
20
|
+
LOCATION = "LOCATION"
|
|
21
|
+
|
|
22
|
+
# Python's Unicode classes, spelled out: Ruby's `\w`, `\d`, `\s` are ASCII.
|
|
23
|
+
W = "\\p{L}\\p{N}_"
|
|
24
|
+
LETTER = "\\p{L}\\p{Nl}\\p{No}"
|
|
25
|
+
SPACE = "[:space:]\\u001c-\\u001f"
|
|
26
|
+
PY_END = "(?=\\n?\\z)" # Python `$` without re.M
|
|
27
|
+
private_constant :W, :LETTER, :SPACE, :PY_END
|
|
28
|
+
|
|
29
|
+
FORMAL_TITLES = Set.new(%w[
|
|
30
|
+
mr mrs ms miss mx dr doctor prof professor coach principal officer sgt sergeant
|
|
31
|
+
capt captain rev reverend pastor rabbi imam nurse sen senator rep gov governor
|
|
32
|
+
mayor sir dame madam chief fr mister judge detective lord lady
|
|
33
|
+
]).freeze
|
|
34
|
+
KIN_TITLES = Set.new(%w[
|
|
35
|
+
aunt auntie uncle grandma grandpa grandmother grandfather cousin sister brother
|
|
36
|
+
father mother
|
|
37
|
+
]).freeze
|
|
38
|
+
TITLES = (FORMAL_TITLES | KIN_TITLES).freeze
|
|
39
|
+
|
|
40
|
+
APOSTROPHES = "'’ʼ"
|
|
41
|
+
POSSESSIVE = Regexp.new("(?:[#{APOSTROPHES}][sS]|(?<=[sS])[#{APOSTROPHES}])#{PY_END}")
|
|
42
|
+
LETTERS = Regexp.new("[#{LETTER}]+")
|
|
43
|
+
SEPARATORS = Regexp.new("([#{SPACE}]+|,)")
|
|
44
|
+
SEPARATOR_FULL = Regexp.new("\\A(?:[#{SPACE}]+|,)\\z")
|
|
45
|
+
EDGE = Regexp.new("\\A([^#{W}]*)(.*?)([^#{W}]*)#{PY_END}", Regexp::MULTILINE)
|
|
46
|
+
WS_SPLIT = Regexp.new("([#{SPACE}]+)")
|
|
47
|
+
WS_ONLY = Regexp.new("\\A[#{SPACE}]+\\z")
|
|
48
|
+
ND = /\p{Nd}/
|
|
49
|
+
# Python `str.isdigit`: Nd plus the Numeric_Type=Digit No characters.
|
|
50
|
+
DIGIT = /[\p{Nd}²³¹⁰⁴-⁹₀-₉①-⑨⑴-⑼⒈-⒐⓪⓵-⓽⓿❶-❾➀-➈➊-➒፩-፱᧚]/
|
|
51
|
+
|
|
52
|
+
DESCRIPTIVE = Set.new(%w[
|
|
53
|
+
elementary middle high school schools academy college university prep preparatory
|
|
54
|
+
charter montessori institute junior senior jr sr community christian catholic day
|
|
55
|
+
district unified isd usd magnet primary secondary intermediate kindergarten
|
|
56
|
+
preschool early learning center centre club inc llc ltd co corp corporation company
|
|
57
|
+
church temple mosque synagogue foundation library museum hospital clinic team league
|
|
58
|
+
society association council scouts troop band choir orchestra park lake river creek
|
|
59
|
+
county city town township village mount mt valley beach island springs falls heights
|
|
60
|
+
hills bay harbor port north south east west new saint st fort the of and & for at in
|
|
61
|
+
on state national public
|
|
62
|
+
]).freeze
|
|
63
|
+
SCHOOL_CUES = Set.new(%w[elementary school academy college university]).freeze
|
|
64
|
+
SCHOOL_TAIL_CUES = Set.new(%w[high middle]).freeze
|
|
65
|
+
ORG_CUES = Set.new(%w[
|
|
66
|
+
inc llc ltd corp corporation company club church foundation library museum society
|
|
67
|
+
association league council center centre
|
|
68
|
+
]).freeze
|
|
69
|
+
RUN_POOLS = { SCHOOL => "school", ORGANIZATION => "org", LOCATION => "place" }.freeze
|
|
70
|
+
|
|
71
|
+
DIRECTIONS = Set.new(%w[
|
|
72
|
+
n s e w ne nw se sw north south east west northeast northwest southeast southwest
|
|
73
|
+
]).freeze
|
|
74
|
+
STREET_SUFFIXES = Set.new(%w[
|
|
75
|
+
street st avenue ave boulevard blvd road rd drive dr lane ln court ct circle cir
|
|
76
|
+
place pl terrace ter way parkway pkwy highway hwy trail trl square sq loop alley
|
|
77
|
+
commons
|
|
78
|
+
]).freeze
|
|
79
|
+
UNIT = Regexp.new("([#{SPACE}]*(?:(?:Apt|Apartment|Suite|Ste|Unit)\\.?|\\#)[#{SPACE}]*)" \
|
|
80
|
+
"([#{W}-]+)([^#{W}]*)#{PY_END}", Regexp::IGNORECASE)
|
|
81
|
+
|
|
82
|
+
PROVIDERS = Set.new(%w[
|
|
83
|
+
gmail.com yahoo.com outlook.com hotmail.com icloud.com aol.com protonmail.com
|
|
84
|
+
proton.me live.com msn.com me.com mac.com comcast.net
|
|
85
|
+
]).freeze
|
|
86
|
+
URL_SHAPES = [
|
|
87
|
+
["youtube.com", "/@{h}"], ["youtube.com", "/c/{h}"],
|
|
88
|
+
["youtube.com", "/channel/{i}"], ["youtube.com", "/user/{h}"],
|
|
89
|
+
["instagram.com", "/stories/{h}"], ["instagram.com", "/{h}"],
|
|
90
|
+
["twitter.com", "/{h}/status/{i}"], ["twitter.com", "/{h}"],
|
|
91
|
+
["x.com", "/{h}/status/{i}"], ["x.com", "/{h}"],
|
|
92
|
+
["tiktok.com", "/@{h}/video/{i}"], ["tiktok.com", "/@{h}"],
|
|
93
|
+
["threads.net", "/@{h}"], ["threads.com", "/@{h}"],
|
|
94
|
+
["facebook.com", "/profile.php?id={i}"], ["facebook.com", "/people/{h}/{i}"],
|
|
95
|
+
["facebook.com", "/{h}"],
|
|
96
|
+
["linkedin.com", "/in/{h}"], ["linkedin.com", "/pub/{h}"],
|
|
97
|
+
["github.com", "/orgs/{h}"], ["github.com", "/sponsors/{h}"], ["github.com", "/{h}"],
|
|
98
|
+
["reddit.com", "/user/{h}"], ["reddit.com", "/u/{h}"],
|
|
99
|
+
["old.reddit.com", "/user/{h}"], ["old.reddit.com", "/u/{h}"],
|
|
100
|
+
["medium.com", "/@{h}"], ["mastodon.social", "/@{h}"],
|
|
101
|
+
["bsky.app", "/profile/{h}"], ["snapchat.com", "/add/{h}"],
|
|
102
|
+
["pinterest.com", "/{h}"], ["twitch.tv", "/{h}"], ["soundcloud.com", "/{h}"],
|
|
103
|
+
["linktr.ee", "/{h}"], ["vsco.co", "/{h}"], ["patreon.com", "/{h}"],
|
|
104
|
+
["tumblr.com", "/{h}"], ["deviantart.com", "/{h}"], ["codepen.io", "/{h}"],
|
|
105
|
+
["replit.com", "/@{h}"], ["scratch.mit.edu", "/users/{h}"],
|
|
106
|
+
["roblox.com", "/users/{i}"], ["khanacademy.org", "/profile/{h}"],
|
|
107
|
+
["open.spotify.com", "/user/{h}"], ["steamcommunity.com", "/id/{h}"],
|
|
108
|
+
["steamcommunity.com", "/profiles/{i}"], ["flickr.com", "/people/{h}"],
|
|
109
|
+
["flickr.com", "/photos/{h}"], ["wattpad.com", "/user/{h}"],
|
|
110
|
+
["archiveofourown.org", "/users/{h}"], ["quizlet.com", "/user/{h}"],
|
|
111
|
+
["goodreads.com", "/user/show/{h}"]
|
|
112
|
+
].map(&:freeze).freeze
|
|
113
|
+
URL_RESERVED = Set.new(%w[
|
|
114
|
+
about accounts add apps c channel collections compose dashboard developer direct
|
|
115
|
+
directory discover enterprise events explore features gaming groups hashtag help
|
|
116
|
+
home i ideas intent issues join legal login marketplace messages new notifications
|
|
117
|
+
orgs p pages people permalink.php photo.php pin policies pricing privacy profile.php
|
|
118
|
+
pulls reel reels register search settings share sharer.php signup sponsors stories
|
|
119
|
+
stream tagged today topics tos trending tv upload user videos watch you
|
|
120
|
+
]).freeze
|
|
121
|
+
URL_USER_SUBDOMAINS = Set.new(%w[
|
|
122
|
+
substack.com tumblr.com blogspot.com wordpress.com github.io neocities.org
|
|
123
|
+
weebly.com wixsite.com carrd.co
|
|
124
|
+
]).freeze
|
|
125
|
+
|
|
126
|
+
SSN_PLUG = "799-99-9999"
|
|
127
|
+
ZIP_PLUG = "99999"
|
|
128
|
+
PHONE_AREA_CODE = "201"
|
|
129
|
+
PHONE_PLUG = "201-555-0100"
|
|
130
|
+
IP_RANGES = ["192.0.2.", "198.51.100.", "203.0.113."].freeze
|
|
131
|
+
CARD_PLUGS = [
|
|
132
|
+
[%w[34 37], 15, "378282246310005"],
|
|
133
|
+
[%w[4], 16, "4111111111111111"],
|
|
134
|
+
[%w[4], 13, "4222222222222"],
|
|
135
|
+
[%w[51 52 53 54 55 22 23 24 25 26 27], 16, "5555555555554444"],
|
|
136
|
+
[%w[6011 64 65], 16, "6011111111111117"],
|
|
137
|
+
[%w[30 36 38], 14, "30569309025904"],
|
|
138
|
+
[%w[35], 16, "3530111333300000"]
|
|
139
|
+
].freeze
|
|
140
|
+
AGE_BANDS = [
|
|
141
|
+
[0, 4], [5, 7], [8, 10], [11, 13], [14, 18], [19, 24], [25, 34],
|
|
142
|
+
[35, 44], [45, 54], [55, 64], [65, 74], [75, 84], [85, 99]
|
|
143
|
+
].freeze
|
|
144
|
+
DATE = /(\p{Nd}{1,2})([\/-])(\p{Nd}{1,2})\2(\p{Nd}{2,4})/
|
|
145
|
+
ACRONYM = /\A[A-Z]{3,6}\z/
|
|
146
|
+
MAX_BUMPS = 256
|
|
147
|
+
PLUG_KINDS = Set.new(%w[US_SOCIAL_SECURITY_NUMBER ZIP_CODE IP_ADDRESS
|
|
148
|
+
CREDIT_DEBIT_CARD_NUMBER PHONE]).freeze
|
|
149
|
+
POOL_NAMES = %w[given org place school street surname].freeze
|
|
150
|
+
|
|
151
|
+
# One chunk of a name span: kind is "sep" | "title" | "name" | "raw".
|
|
152
|
+
Part = Struct.new(:kind, :text, :key, :lead, :tail, :chunk, :plural) do
|
|
153
|
+
def self.of(kind, text, key: "", lead: "", tail: "", chunk: 0, plural: false)
|
|
154
|
+
new(kind, text, key, lead, tail, chunk, plural)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# What {invent} returns: one stand-in per span, and `{invented: original}` tokens.
|
|
159
|
+
Invented = Struct.new(:stand_ins, :tokens)
|
|
160
|
+
|
|
161
|
+
class << self
|
|
162
|
+
# -- Python string semantics ------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def fold(token)
|
|
165
|
+
folded = token.unicode_normalize(:nfc).downcase(:fold)
|
|
166
|
+
folded.tr("’ʼ", "''")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def rstrip_dots(str) = str.sub(/\.+\z/, "")
|
|
170
|
+
|
|
171
|
+
# `str.split(sep)` with Python's answer for "": [""].
|
|
172
|
+
def py_split(str, sep)
|
|
173
|
+
str.empty? ? [""] : str.split(sep, -1)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cased_chars?(str, cased, other)
|
|
177
|
+
found = false
|
|
178
|
+
str.each_char do |c|
|
|
179
|
+
return false if c.match?(other) || c.match?(/\p{Lt}/)
|
|
180
|
+
|
|
181
|
+
found = true if c.match?(cased)
|
|
182
|
+
end
|
|
183
|
+
found
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def py_lower?(str) = cased_chars?(str, /\p{Lowercase}/, /\p{Uppercase}/)
|
|
187
|
+
def py_upper?(str) = cased_chars?(str, /\p{Uppercase}/, /\p{Lowercase}/)
|
|
188
|
+
def py_alpha?(char) = !char.empty? && char.match?(/\A\p{L}+\z/)
|
|
189
|
+
def py_digits?(str) = !str.empty? && str.each_char.all? { |c| c.match?(DIGIT) }
|
|
190
|
+
|
|
191
|
+
# Value of a decimal digit: Nd runs are contiguous 0..9 sets.
|
|
192
|
+
def nd_value(char)
|
|
193
|
+
cp = char.ord
|
|
194
|
+
back = 0
|
|
195
|
+
back += 1 while cp - back - 1 >= 0 && [cp - back - 1].pack("U").match?(ND)
|
|
196
|
+
back % 10
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def py_int(str) = str.each_char.reduce(0) { |acc, c| (acc * 10) + nd_value(c) }
|
|
200
|
+
|
|
201
|
+
def letters_of(str) = str.scan(LETTERS).join
|
|
202
|
+
|
|
203
|
+
# -- pools -------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# Where the vendored pools live: the gem's copy, then the Python package's.
|
|
206
|
+
def pool_search_path
|
|
207
|
+
Asset.search_path.map { |dir| dir.join("pools") } +
|
|
208
|
+
[Pathname.new(__dir__).join("..", "..", "..", "python", "src", "vicary", "pools").expand_path]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def pool_path(name)
|
|
212
|
+
file = "#{name}.txt"
|
|
213
|
+
dir = pool_search_path.find { |d| d.join(file).file? }
|
|
214
|
+
raise Asset::MissingAssetError, "no adlib pool #{file} in #{pool_search_path.join(', ')}" unless dir
|
|
215
|
+
|
|
216
|
+
dir.join(file)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def load_pool(name)
|
|
220
|
+
@pools ||= {}
|
|
221
|
+
@pools[name] ||= pool_path(name).read(encoding: "UTF-8")
|
|
222
|
+
.split(/\r\n|[\n\r\v\f\u001c-\u001e\u0085
]/)
|
|
223
|
+
.map(&:strip).reject(&:empty?).map(&:freeze).freeze
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def given_pool = load_pool("given")
|
|
227
|
+
def surname_pool = load_pool("surname")
|
|
228
|
+
|
|
229
|
+
# -- the draw ----------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def draw(seed, document_digest, purpose, key, bump, index, n)
|
|
232
|
+
material = [seed, document_digest, purpose, key, "#{bump}.#{index}"].join("\0")
|
|
233
|
+
Digest::SHA256.digest(material.encode("UTF-8"))[0, 8].unpack1("Q>") % n
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# -- names -------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def split_span(original)
|
|
239
|
+
parts = []
|
|
240
|
+
py_split(original, SEPARATORS).each_with_index do |chunk, index|
|
|
241
|
+
next if chunk.empty?
|
|
242
|
+
|
|
243
|
+
if chunk.match?(SEPARATOR_FULL)
|
|
244
|
+
parts << Part.of("sep", chunk)
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
lead, core, tail = EDGE.match(chunk).captures
|
|
248
|
+
if (pos = core =~ POSSESSIVE)
|
|
249
|
+
tail = core[pos..] + tail
|
|
250
|
+
core = core[0...pos]
|
|
251
|
+
end
|
|
252
|
+
if core.empty?
|
|
253
|
+
parts << Part.of("raw", chunk)
|
|
254
|
+
next
|
|
255
|
+
end
|
|
256
|
+
if TITLES.include?(rstrip_dots(fold(core))) && parts.none? { |p| p.kind == "name" }
|
|
257
|
+
parts << Part.of("title", chunk)
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
pieces = core.split("-", -1)
|
|
261
|
+
pieces.each_with_index do |piece, i|
|
|
262
|
+
piece_lead = i.zero? ? lead : "-"
|
|
263
|
+
piece_tail = i == pieces.length - 1 ? tail : ""
|
|
264
|
+
unless piece.match?(LETTERS)
|
|
265
|
+
parts << Part.of("raw", piece_lead + piece + piece_tail)
|
|
266
|
+
next
|
|
267
|
+
end
|
|
268
|
+
parts << Part.of("name", piece, key: rstrip_dots(fold(piece)), lead: piece_lead,
|
|
269
|
+
tail: piece_tail, chunk: index)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
parts
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def initial?(key) = key.scan(LETTERS).length == 1 && rstrip_dots(key).length == 1
|
|
276
|
+
|
|
277
|
+
def roles(spans, given_name)
|
|
278
|
+
votes = {}
|
|
279
|
+
spans.each do |parts|
|
|
280
|
+
names = parts.select { |p| p.kind == "name" && !initial?(p.key) }
|
|
281
|
+
next if names.empty?
|
|
282
|
+
|
|
283
|
+
names.each { |p| votes[p.key] ||= [0, 0] }
|
|
284
|
+
comma = parts.any? { |p| p.kind == "sep" && p.text == "," }
|
|
285
|
+
titled = parts.any? { |p| p.kind == "title" && FORMAL_TITLES.include?(rstrip_dots(fold(p.text))) }
|
|
286
|
+
chunks = names.map(&:chunk).uniq.sort
|
|
287
|
+
if titled
|
|
288
|
+
names.each { |p| p.chunk == chunks[-1] ? votes[p.key][1] += 2 : votes[p.key][0] += 1 }
|
|
289
|
+
elsif chunks.length > 1
|
|
290
|
+
surname = comma ? chunks[0] : chunks[-1]
|
|
291
|
+
names.each { |p| votes[p.key][p.chunk == surname ? 1 : 0] += 1 }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
votes.to_h do |key, (given, surname)|
|
|
295
|
+
role = if given != surname then given > surname ? GIVEN : SURNAME
|
|
296
|
+
elsif given_name && !given_name.call(key) then SURNAME
|
|
297
|
+
else GIVEN
|
|
298
|
+
end
|
|
299
|
+
[key, role]
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def cased(stand_in, like)
|
|
304
|
+
letters = letters_of(like)
|
|
305
|
+
return stand_in.downcase if py_lower?(letters)
|
|
306
|
+
return stand_in.upcase if py_upper?(letters) && letters.length > 1
|
|
307
|
+
|
|
308
|
+
stand_in
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def apostrophe_tail?(tail)
|
|
312
|
+
!tail.empty? && APOSTROPHES.include?(tail[0]) && (tail.length == 1 || !py_alpha?(tail[1]))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def possessive_tail(tail, stand_in)
|
|
316
|
+
if apostrophe_tail?(tail) && !stand_in.downcase.end_with?("s")
|
|
317
|
+
return tail[0] + (py_upper?(stand_in) ? "S" : "s") + tail[1..]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
tail
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def initials(seed, digest, keys, forbidden)
|
|
324
|
+
alphabet = ("a".."z").to_a.join
|
|
325
|
+
out = {}
|
|
326
|
+
used = Set.new
|
|
327
|
+
keys.each do |key|
|
|
328
|
+
letter = rstrip_dots(key)
|
|
329
|
+
next if out.key?(letter)
|
|
330
|
+
|
|
331
|
+
start = draw(seed, digest, "initial", letter, 0, 0, alphabet.length)
|
|
332
|
+
candidate = (0...alphabet.length).lazy.map { |s| alphabet[(start + s) % alphabet.length] }
|
|
333
|
+
.find { |c| !forbidden.include?(c) && !used.include?(c) }
|
|
334
|
+
candidate ||= letter
|
|
335
|
+
out[letter] = candidate
|
|
336
|
+
used << candidate
|
|
337
|
+
end
|
|
338
|
+
out
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def plural(word) = word + (word.downcase.end_with?("s", "x", "z", "ch", "sh") ? "es" : "s")
|
|
342
|
+
|
|
343
|
+
def plural_possessive?(part) = apostrophe_tail?(part.tail) && part.key.end_with?("s")
|
|
344
|
+
|
|
345
|
+
def resolve_plurals(spans)
|
|
346
|
+
bases = Set.new
|
|
347
|
+
spans.each_value { |ps| ps.each { |p| bases << p.key if p.kind == "name" && !plural_possessive?(p) } }
|
|
348
|
+
spans.each_value do |parts|
|
|
349
|
+
parts.each_with_index do |p, i|
|
|
350
|
+
next if p.kind != "name" || !plural_possessive?(p)
|
|
351
|
+
|
|
352
|
+
[1, 2].each do |cut|
|
|
353
|
+
base = p.key.length >= cut ? p.key[0...-cut] : ""
|
|
354
|
+
next unless (cut == 1 || p.key.end_with?("es")) && bases.include?(base)
|
|
355
|
+
|
|
356
|
+
parts[i] = p.dup.tap { |q| q.key = base; q.plural = true }
|
|
357
|
+
break
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def acronym(name) = name.scan(Regexp.new("[A-Za-z][#{W}'-]*")).map { |w| w[0] }.join.upcase
|
|
364
|
+
|
|
365
|
+
def luhn_digit(payload)
|
|
366
|
+
total = 0
|
|
367
|
+
payload.reverse.each_char.with_index do |ch, i|
|
|
368
|
+
d = (ch.ord - 48) * (i.even? ? 2 : 1)
|
|
369
|
+
total += d > 9 ? d - 9 : d
|
|
370
|
+
end
|
|
371
|
+
((10 - (total % 10)) % 10).to_s
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def fill_digits(original, digits)
|
|
375
|
+
it = digits.each_char
|
|
376
|
+
original.each_char.map do |c|
|
|
377
|
+
next c unless c.match?(DIGIT)
|
|
378
|
+
|
|
379
|
+
begin
|
|
380
|
+
it.next
|
|
381
|
+
rescue StopIteration
|
|
382
|
+
raise RuntimeError, "generator raised StopIteration"
|
|
383
|
+
end
|
|
384
|
+
end.join
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def card_plug(digits, variant)
|
|
388
|
+
hit = CARD_PLUGS.find { |prefixes, length, _| length == digits.length && digits.start_with?(*prefixes) }
|
|
389
|
+
base = hit&.last
|
|
390
|
+
if base.nil?
|
|
391
|
+
payload = "4" + ("1" * [digits.length - 2, 0].max)
|
|
392
|
+
base = payload + luhn_digit(payload)
|
|
393
|
+
end
|
|
394
|
+
return base if variant.zero?
|
|
395
|
+
|
|
396
|
+
payload = base[0...-1]
|
|
397
|
+
tail4 = payload.length >= 4 ? payload[-4..] : payload
|
|
398
|
+
last4 = (tail4.to_i - variant) % 10_000
|
|
399
|
+
payload = payload[0...[payload.length - 4, 0].max] + format("%04d", last4)
|
|
400
|
+
payload + luhn_digit(payload)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def days(year)
|
|
404
|
+
leap = (year % 4).zero? && ((year % 100).nonzero? || (year % 400).zero?)
|
|
405
|
+
[31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def shift_date(date, offset)
|
|
409
|
+
m = DATE.match(date)
|
|
410
|
+
return date if m.nil?
|
|
411
|
+
|
|
412
|
+
first, sep, second, year_text = m.captures
|
|
413
|
+
day_first = py_int(first) > 12
|
|
414
|
+
month_text, day_text = day_first ? [second, first] : [first, second]
|
|
415
|
+
year = py_int(year_text) + (year_text.length == 2 ? 2000 : 0)
|
|
416
|
+
ds = days(year)
|
|
417
|
+
month = py_int(month_text).clamp(1, 12)
|
|
418
|
+
day = py_int(day_text).clamp(1, ds[month - 1])
|
|
419
|
+
doy = ds[0...(month - 1)].sum + day
|
|
420
|
+
doy = ((doy - 1 + offset) % ds.sum) + 1
|
|
421
|
+
month = 1
|
|
422
|
+
while doy > ds[month - 1]
|
|
423
|
+
doy -= ds[month - 1]
|
|
424
|
+
month += 1
|
|
425
|
+
end
|
|
426
|
+
new_month = month.to_s.rjust(month_text.length, "0")
|
|
427
|
+
new_day = doy.to_s.rjust(day_text.length, "0")
|
|
428
|
+
fields = day_first ? [new_day, new_month] : [new_month, new_day]
|
|
429
|
+
date[0...m.begin(0)] + "#{fields[0]}#{sep}#{fields[1]}#{sep}#{year_text}" + date[m.end(0)..]
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def effective_kind(kind, original)
|
|
433
|
+
kind = NAME if kind == "PERSON"
|
|
434
|
+
return kind if kind != NAME
|
|
435
|
+
|
|
436
|
+
words = original.split(Regexp.new("[#{SPACE}]+")).reject(&:empty?).map { |w| rstrip_dots(fold(w)) }
|
|
437
|
+
return kind if words.empty?
|
|
438
|
+
return SCHOOL if words.any? { |w| SCHOOL_CUES.include?(w) } || SCHOOL_TAIL_CUES.include?(words[-1])
|
|
439
|
+
return ORGANIZATION if words.any? { |w| ORG_CUES.include?(w) }
|
|
440
|
+
|
|
441
|
+
kind
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A stand-in for every `[kind, original]` span of one document.
|
|
445
|
+
def invent(spans, document:, seed: 0, stop_words: Set.new, given_name: nil, notable: nil,
|
|
446
|
+
identity: [])
|
|
447
|
+
engine = Engine.new(document: document, seed: seed, stop_words: stop_words,
|
|
448
|
+
identity: identity, given_name: given_name, notable: notable)
|
|
449
|
+
engine.invent_all(spans)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def invent_names(originals, **kwargs)
|
|
453
|
+
result = invent(originals.map { |o| [NAME, o] }, **kwargs)
|
|
454
|
+
originals.zip(result.stand_ins).to_h
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Put originals back into text carrying adlib stand-ins (a model reply).
|
|
458
|
+
def restore_adlib(text, restore_map, token_map = nil)
|
|
459
|
+
table = {}
|
|
460
|
+
[restore_map, token_map || {}].each do |mapping|
|
|
461
|
+
mapping.each { |key, original| table[fold(key)] = original if !key.empty? && !table.key?(fold(key)) }
|
|
462
|
+
end
|
|
463
|
+
return text if table.empty?
|
|
464
|
+
|
|
465
|
+
keys = table.keys.sort_by { |k| [-k.length, k] }
|
|
466
|
+
pattern = Regexp.new("(?<![#{W}])(?:#{keys.map { |k| Regexp.escape(k) }.join('|')})(?![#{W}])",
|
|
467
|
+
Regexp::IGNORECASE)
|
|
468
|
+
text.gsub(pattern) do |seen|
|
|
469
|
+
original = table.fetch(fold(seen))
|
|
470
|
+
letters = letters_of(seen)
|
|
471
|
+
if py_upper?(letters) && letters.length > 1 then original.upcase
|
|
472
|
+
elsif py_lower?(letters) then original.downcase
|
|
473
|
+
else original
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# One document's adlib state: the taken words, the maps, the used sets.
|
|
480
|
+
class Engine
|
|
481
|
+
attr_accessor :dob_offset
|
|
482
|
+
attr_reader :tokens
|
|
483
|
+
|
|
484
|
+
def initialize(document:, seed:, stop_words:, identity:, given_name:, notable:)
|
|
485
|
+
@seed = seed.to_s
|
|
486
|
+
@digest = Digest::SHA256.hexdigest(document.encode("UTF-8"))
|
|
487
|
+
a = Adlib
|
|
488
|
+
identity_words = identity.flat_map { |v| (v || "").scan(LETTERS).map { |w| a.fold(w) } }
|
|
489
|
+
@taken = Set.new(document.scan(LETTERS).map { |w| a.fold(w) }) | stop_words.to_set | identity_words
|
|
490
|
+
@given_name = given_name
|
|
491
|
+
@notable = notable
|
|
492
|
+
@names = {}
|
|
493
|
+
@prose_names = {}
|
|
494
|
+
@used_names = Set.new
|
|
495
|
+
@stems = {}
|
|
496
|
+
@used_stems = {}
|
|
497
|
+
@tokens = {}
|
|
498
|
+
@dob_offset = 1
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def draw(purpose, key, bump, index, n) = Adlib.draw(@seed, @digest, purpose, key, bump, index, n)
|
|
502
|
+
|
|
503
|
+
def a = Adlib
|
|
504
|
+
|
|
505
|
+
def invent_names(originals)
|
|
506
|
+
spans = {}
|
|
507
|
+
originals.each { |o| spans[o] ||= a.split_span(o) }
|
|
508
|
+
a.resolve_plurals(spans)
|
|
509
|
+
roles = a.roles(spans.values, @given_name)
|
|
510
|
+
order = []
|
|
511
|
+
surface = {}
|
|
512
|
+
spans.each_value do |parts|
|
|
513
|
+
parts.each do |p|
|
|
514
|
+
next unless p.kind == "name" && !a.initial?(p.key) && !surface.key?(p.key)
|
|
515
|
+
|
|
516
|
+
order << p.key
|
|
517
|
+
surface[p.key] = p.text
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
order.each { |key| roles[key] ||= GIVEN }
|
|
521
|
+
pools = { GIVEN => a.given_pool, SURNAME => a.surname_pool }
|
|
522
|
+
bumps = {}
|
|
523
|
+
assign = lambda do
|
|
524
|
+
used = Set.new
|
|
525
|
+
assigned = {}
|
|
526
|
+
order.each do |key|
|
|
527
|
+
pool = pools[roles[key]]
|
|
528
|
+
start = Adlib.draw(@seed, @digest, roles[key], key, bumps.fetch(key, 0), 0, pool.length)
|
|
529
|
+
hit = (0...pool.length).find do |step|
|
|
530
|
+
folded = a.fold(pool[(start + step) % pool.length])
|
|
531
|
+
!@taken.include?(folded) && !used.include?(folded)
|
|
532
|
+
end
|
|
533
|
+
raise AdlibPoolExhausted, "no unused #{roles[key]} stand-in left for this document" if hit.nil?
|
|
534
|
+
|
|
535
|
+
assigned[key] = pool[(start + hit) % pool.length]
|
|
536
|
+
used << a.fold(assigned[key])
|
|
537
|
+
end
|
|
538
|
+
assigned
|
|
539
|
+
end
|
|
540
|
+
assigned = assign.call
|
|
541
|
+
a.surname_pool.length.times do
|
|
542
|
+
break if @notable.nil?
|
|
543
|
+
|
|
544
|
+
clash = nil
|
|
545
|
+
spans.each_value do |parts|
|
|
546
|
+
keys = parts.select { |p| p.kind == "name" && assigned.key?(p.key) }.map(&:key)
|
|
547
|
+
if keys.length > 1 && @notable.call(keys.map { |k| assigned[k] }.join(" "))
|
|
548
|
+
clash = keys[-1]
|
|
549
|
+
break
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
break if clash.nil?
|
|
553
|
+
|
|
554
|
+
bumps[clash] = bumps.fetch(clash, 0) + 1
|
|
555
|
+
assigned = assign.call
|
|
556
|
+
end
|
|
557
|
+
@names = assigned.dup
|
|
558
|
+
@prose_names = assigned.dup
|
|
559
|
+
@used_names = Set.new(assigned.values.map { |v| a.fold(v) })
|
|
560
|
+
assigned.each { |key, stand_in| @tokens[stand_in] = surface[key] unless @tokens.key?(stand_in) }
|
|
561
|
+
|
|
562
|
+
initial_keys = spans.values.flatten.select { |p| p.kind == "name" && a.initial?(p.key) }.map(&:key)
|
|
563
|
+
initials = a.initials(@seed, @digest, initial_keys, Set.new(initial_keys.map { |k| a.rstrip_dots(k) }))
|
|
564
|
+
spans.to_h do |original, parts|
|
|
565
|
+
rendered = parts.map do |p|
|
|
566
|
+
next p.text if p.kind != "name"
|
|
567
|
+
|
|
568
|
+
if a.initial?(p.key)
|
|
569
|
+
letter = initials[a.rstrip_dots(p.key)]
|
|
570
|
+
target = a.rstrip_dots(p.text)[-1]
|
|
571
|
+
core = p.text.sub(target) { a.cased(letter.upcase, p.text) }
|
|
572
|
+
p.lead + core + a.possessive_tail(p.tail, core)
|
|
573
|
+
elsif p.plural
|
|
574
|
+
p.lead + a.cased(a.plural(assigned[p.key]), p.text) + p.tail
|
|
575
|
+
else
|
|
576
|
+
core = a.cased(assigned[p.key], p.text)
|
|
577
|
+
p.lead + core + a.possessive_tail(p.tail, core)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
[original, rendered.join]
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def name_token(key, like)
|
|
585
|
+
unless @names.key?(key)
|
|
586
|
+
pool = a.given_pool
|
|
587
|
+
start = draw(GIVEN, key, 0, 0, pool.length)
|
|
588
|
+
hit = (0...pool.length).find do |step|
|
|
589
|
+
folded = a.fold(pool[(start + step) % pool.length])
|
|
590
|
+
!@taken.include?(folded) && !@used_names.include?(folded)
|
|
591
|
+
end
|
|
592
|
+
raise AdlibPoolExhausted, "no unused given stand-in left for this document" if hit.nil?
|
|
593
|
+
|
|
594
|
+
candidate = pool[(start + hit) % pool.length]
|
|
595
|
+
@names[key] = candidate
|
|
596
|
+
@used_names << a.fold(candidate)
|
|
597
|
+
@tokens[candidate] = like unless @tokens.key?(candidate)
|
|
598
|
+
end
|
|
599
|
+
a.cased(@names[key], like)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def stem(pool_name, key, like)
|
|
603
|
+
memo = [pool_name, key]
|
|
604
|
+
unless @stems.key?(memo)
|
|
605
|
+
pool = a.load_pool(pool_name)
|
|
606
|
+
used = (@used_stems[pool_name] ||= Set.new)
|
|
607
|
+
start = draw(pool_name, key, 0, 0, pool.length)
|
|
608
|
+
hit = (0...pool.length).find do |step|
|
|
609
|
+
candidate = pool[(start + step) % pool.length]
|
|
610
|
+
words = Set.new(candidate.scan(LETTERS).map { |w| a.fold(w) })
|
|
611
|
+
!used.include?(a.fold(candidate)) && !words.intersect?(@taken)
|
|
612
|
+
end
|
|
613
|
+
raise AdlibPoolExhausted, "no unused #{pool_name} stem left for this document" if hit.nil?
|
|
614
|
+
|
|
615
|
+
candidate = pool[(start + hit) % pool.length]
|
|
616
|
+
used << a.fold(candidate)
|
|
617
|
+
@stems[memo] = candidate
|
|
618
|
+
@tokens[candidate] = like unless @tokens.key?(candidate)
|
|
619
|
+
end
|
|
620
|
+
a.cased(@stems[memo], like)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def runs(text, pool_name, kept)
|
|
624
|
+
out = []
|
|
625
|
+
run = []
|
|
626
|
+
pending = +""
|
|
627
|
+
flush = lambda do
|
|
628
|
+
unless run.empty?
|
|
629
|
+
like = run.map { |r| r[1] }.join(" ")
|
|
630
|
+
key = run.map { |r| a.fold(r[1]) }.join(" ")
|
|
631
|
+
out << (run[0][0] + stem(pool_name, key, like) + run[-1][2])
|
|
632
|
+
run.clear
|
|
633
|
+
end
|
|
634
|
+
out << pending
|
|
635
|
+
pending = +""
|
|
636
|
+
end
|
|
637
|
+
a.py_split(text, WS_SPLIT).each do |chunk|
|
|
638
|
+
next if chunk.empty?
|
|
639
|
+
|
|
640
|
+
if chunk.match?(WS_ONLY)
|
|
641
|
+
run.empty? ? out << chunk : pending << chunk
|
|
642
|
+
next
|
|
643
|
+
end
|
|
644
|
+
lead, core, tail = EDGE.match(chunk).captures
|
|
645
|
+
if (pos = core =~ POSSESSIVE)
|
|
646
|
+
tail = core[pos..] + tail
|
|
647
|
+
core = core[0...pos]
|
|
648
|
+
end
|
|
649
|
+
if !core.match?(LETTERS) || kept.call(a.rstrip_dots(a.fold(core)))
|
|
650
|
+
flush.call
|
|
651
|
+
out << chunk
|
|
652
|
+
next
|
|
653
|
+
end
|
|
654
|
+
flush.call if !run.empty? && !run[-1][2].empty?
|
|
655
|
+
pending = +""
|
|
656
|
+
run << [lead, core, tail]
|
|
657
|
+
end
|
|
658
|
+
flush.call
|
|
659
|
+
out.join
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def scramble(original, bump)
|
|
663
|
+
original.each_char.with_index.map do |c, i|
|
|
664
|
+
if c.match?(DIGIT) then draw("scramble", original, bump, i, 10).to_s
|
|
665
|
+
elsif c.between?("a", "z") && c.length == 1 && c.ord < 128 then (97 + draw("scramble", original, bump, i, 26)).chr
|
|
666
|
+
elsif c.between?("A", "Z") && c.ord < 128 then (65 + draw("scramble", original, bump, i, 26)).chr
|
|
667
|
+
else c
|
|
668
|
+
end
|
|
669
|
+
end.join
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def labels(text)
|
|
673
|
+
a.py_split(text, /([A-Za-z]+|\p{Nd}+)/).map do |piece|
|
|
674
|
+
next "" if piece.empty?
|
|
675
|
+
|
|
676
|
+
if a.py_digits?(piece)
|
|
677
|
+
piece.length >= 3 ? scramble(piece, 0) : piece
|
|
678
|
+
elsif piece.match?(/\A[A-Za-z]+\z/)
|
|
679
|
+
key = a.fold(piece)
|
|
680
|
+
if @names.key?(key) then a.cased(@names[key], piece)
|
|
681
|
+
elsif !(joined = joined_names(key)).empty? then a.cased(joined, piece)
|
|
682
|
+
elsif piece.length <= 2 then piece
|
|
683
|
+
else name_token(key, piece)
|
|
684
|
+
end
|
|
685
|
+
else piece
|
|
686
|
+
end
|
|
687
|
+
end.join
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def joined_names(key)
|
|
691
|
+
(2...(key.length - 1)).each do |cut|
|
|
692
|
+
return @names[key[0...cut]] + @names[key[cut..]] if @names.key?(key[0...cut]) && @names.key?(key[cut..])
|
|
693
|
+
end
|
|
694
|
+
""
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def email(original, bump)
|
|
698
|
+
local, _, domain = original.rpartition("@")
|
|
699
|
+
local = labels(local) + (bump.zero? ? "" : (bump + 1).to_s)
|
|
700
|
+
unless PROVIDERS.include?(a.fold(domain))
|
|
701
|
+
tld = domain.split(".", -1)[-1] || ""
|
|
702
|
+
domain = "example." + (%w[com net org].include?(tld.downcase) ? tld : "org")
|
|
703
|
+
end
|
|
704
|
+
"#{local}@#{domain}"
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
URL = %r{\A((?:[a-z][a-z0-9+.-]*://)?)([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?\z}im
|
|
708
|
+
HOST = /\A((?:www|m|mobile)\.)?(.*)\z/im
|
|
709
|
+
|
|
710
|
+
def url(original, bump)
|
|
711
|
+
scheme, host, path, query, fragment = URL.match(original).captures.map { |g| g || "" }
|
|
712
|
+
hm = HOST.match(host)
|
|
713
|
+
lead = hm[1] || ""
|
|
714
|
+
bare = hm[2]
|
|
715
|
+
changed = url_shape(bare, path, query)
|
|
716
|
+
if changed.nil?
|
|
717
|
+
label, dot, suffix = bare.partition(".")
|
|
718
|
+
if !dot.empty? && URL_USER_SUBDOMAINS.include?(a.fold(suffix)) && !label.empty? &&
|
|
719
|
+
!URL_RESERVED.include?(a.fold(label)) && a.fold(label) != "www"
|
|
720
|
+
changed = [labels(label) + dot + suffix, path, query]
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
if changed.nil?
|
|
724
|
+
stem_, dot, tld = bare.rpartition(".")
|
|
725
|
+
bare = mapped_words(stem_) + dot + tld unless dot.empty?
|
|
726
|
+
path, query, fragment = [path, query, fragment].map { |t| mapped_words(t) }
|
|
727
|
+
else
|
|
728
|
+
bare, path, query = changed
|
|
729
|
+
end
|
|
730
|
+
scheme + lead + bare + path + query + fragment + (bump.zero? ? "" : (bump + 1).to_s)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def mapped_words(text)
|
|
734
|
+
a.py_split(text, /([A-Za-z]+)/).map do |piece|
|
|
735
|
+
key = a.fold(piece)
|
|
736
|
+
next a.cased(@prose_names[key], piece) if key.length >= 3 && @prose_names.key?(key)
|
|
737
|
+
|
|
738
|
+
cut = (3...(key.length - 2)).find { |c| @prose_names.key?(key[0...c]) && @prose_names.key?(key[c..]) }
|
|
739
|
+
if cut
|
|
740
|
+
a.cased(@prose_names[key[0...cut]], piece[0...cut].to_s) +
|
|
741
|
+
a.cased(@prose_names[key[cut..]], piece[cut..].to_s)
|
|
742
|
+
else
|
|
743
|
+
piece
|
|
744
|
+
end
|
|
745
|
+
end.join
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def url_shape(host, path, query)
|
|
749
|
+
folded_host = a.fold(host)
|
|
750
|
+
segments = a.py_split(path, "/")
|
|
751
|
+
URL_SHAPES.each do |shape_host, pattern|
|
|
752
|
+
next if shape_host != folded_host
|
|
753
|
+
|
|
754
|
+
path_pattern, _, query_pattern = pattern.partition("?")
|
|
755
|
+
wanted = a.py_split(path_pattern, "/")
|
|
756
|
+
next if segments.length < wanted.length || (!query_pattern.empty? && segments.length != wanted.length)
|
|
757
|
+
|
|
758
|
+
out = segments.dup
|
|
759
|
+
matched = wanted.each_with_index.all? do |want, at|
|
|
760
|
+
seg = segments[at]
|
|
761
|
+
if ["{h}", "{i}", "@{h}"].include?(want)
|
|
762
|
+
body = want == "@{h}" ? seg[1..].to_s : seg
|
|
763
|
+
next false if (want == "@{h}" && !seg.start_with?("@")) || body.empty? ||
|
|
764
|
+
URL_RESERVED.include?(a.fold(body))
|
|
765
|
+
|
|
766
|
+
body = want == "{i}" ? scramble(body, 0) : labels(body)
|
|
767
|
+
out[at] = (want == "@{h}" ? "@" : "") + body
|
|
768
|
+
true
|
|
769
|
+
else
|
|
770
|
+
a.fold(seg) == want
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
next unless matched
|
|
774
|
+
|
|
775
|
+
q = query
|
|
776
|
+
unless query_pattern.empty?
|
|
777
|
+
key = query_pattern.partition("=")[0]
|
|
778
|
+
pairs = q.empty? ? [] : a.py_split(q[1..], /([&;])/)
|
|
779
|
+
hit = false
|
|
780
|
+
pairs.each_with_index do |pair, n|
|
|
781
|
+
name, eq, value = pair.partition("=")
|
|
782
|
+
next unless !eq.empty? && a.fold(name) == key && !value.empty?
|
|
783
|
+
|
|
784
|
+
pairs[n] = name + eq + scramble(value, 0)
|
|
785
|
+
hit = true
|
|
786
|
+
end
|
|
787
|
+
next unless hit
|
|
788
|
+
|
|
789
|
+
q = "?" + pairs.join
|
|
790
|
+
end
|
|
791
|
+
return [host, out.join("/"), q]
|
|
792
|
+
end
|
|
793
|
+
nil
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def username(original, bump) = "@" + labels(original[1..].to_s) + (bump.zero? ? "" : (bump + 1).to_s)
|
|
797
|
+
|
|
798
|
+
def address(original, bump)
|
|
799
|
+
unit = UNIT.match(original)
|
|
800
|
+
head = unit ? original[0...unit.begin(0)] : original
|
|
801
|
+
if (number = /\A\p{Nd}+/.match(head))
|
|
802
|
+
head = house_number(number[0], bump) + head[number.end(0)..]
|
|
803
|
+
end
|
|
804
|
+
head = runs(head, "street", ->(w) { DIRECTIONS.include?(w) || STREET_SUFFIXES.include?(w) })
|
|
805
|
+
head += unit[1] + scramble(unit[2], bump) + unit[3] if unit
|
|
806
|
+
head
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def house_number(digits, bump)
|
|
810
|
+
([(1 + draw("address-number", digits, bump, 0, 9)).to_s] +
|
|
811
|
+
(1...digits.length).map { |i| draw("address-number", digits, bump, i, 10).to_s }).join
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def age(original, used, originals)
|
|
815
|
+
value = a.py_int(original)
|
|
816
|
+
lo, hi = AGE_BANDS.find { |l, h| l <= value && value <= h } || [0, 99]
|
|
817
|
+
pool = (lo..hi).reject { |v| v == value }
|
|
818
|
+
free = pool.reject { |v| originals.include?(v.to_s) }
|
|
819
|
+
free = pool if free.empty?
|
|
820
|
+
start = draw("age", original, 0, 0, free.length)
|
|
821
|
+
walk = (0...free.length).map { |s| free[(start + s) % free.length] } + pool.reject { |v| free.include?(v) }
|
|
822
|
+
candidate = walk.map(&:to_s).find { |s| !used.include?(s) } || free[start].to_s
|
|
823
|
+
original.length > candidate.length ? candidate.rjust(original.length, "0") : candidate
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def plug(kind, original, variant)
|
|
827
|
+
case kind
|
|
828
|
+
when "US_SOCIAL_SECURITY_NUMBER"
|
|
829
|
+
return a.fill_digits(original, "79999#{format('%04d', (9999 - variant) % 10_000)}")
|
|
830
|
+
when "ZIP_CODE"
|
|
831
|
+
return a.fill_digits(original, format("%05d", (99_999 - variant) % 100_000) + "9999")
|
|
832
|
+
when "IP_ADDRESS"
|
|
833
|
+
return IP_RANGES[(variant / 254) % 3] + ((variant % 254) + 1).to_s
|
|
834
|
+
when "CREDIT_DEBIT_CARD_NUMBER"
|
|
835
|
+
return a.fill_digits(original, a.card_plug(original.gsub(/\P{Nd}/, ""), variant))
|
|
836
|
+
end
|
|
837
|
+
ext = Regexp.new("[#{SPACE}]*(?:x|ext\\.?|extension)[#{SPACE}]*\\p{Nd}{1,6}#{PY_END}",
|
|
838
|
+
Regexp::IGNORECASE).match(original)
|
|
839
|
+
main = ext ? original[0...ext.begin(0)] : original
|
|
840
|
+
number = main.gsub(/\P{Nd}/, "").chars
|
|
841
|
+
plug7 = "55501#{format('%02d', variant % 100)}".chars
|
|
842
|
+
number = number.length >= 7 ? number[0...-7] + plug7 : plug7
|
|
843
|
+
number[-10...-7] = PHONE_AREA_CODE.chars if number.length >= 10
|
|
844
|
+
a.fill_digits(main, number.join) + (ext ? ext[0] : "")
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def render(kind, original, bump, memo, ages)
|
|
848
|
+
return runs(original, RUN_POOLS[kind], ->(w) { DESCRIPTIVE.include?(w) }) if RUN_POOLS.key?(kind)
|
|
849
|
+
|
|
850
|
+
case kind
|
|
851
|
+
when "EMAIL" then email(original, bump)
|
|
852
|
+
when "URL" then url(original, bump)
|
|
853
|
+
when "USERNAME" then username(original, bump)
|
|
854
|
+
when "ADDRESS" then address(original, bump)
|
|
855
|
+
when "DATE_OF_BIRTH" then a.shift_date(original, @dob_offset)
|
|
856
|
+
else
|
|
857
|
+
if kind == "AGE" && a.py_digits?(original)
|
|
858
|
+
used = Set.new(memo.select { |(k, _), _| k == "AGE" }.values)
|
|
859
|
+
age(original, used, ages)
|
|
860
|
+
elsif PLUG_KINDS.include?(kind) then plug(kind, original, bump)
|
|
861
|
+
else scramble(original, bump)
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def invent_all(spans)
|
|
867
|
+
kinds = spans.map { |kind, original| a.effective_kind(kind, original) }
|
|
868
|
+
pairs = spans.zip(kinds)
|
|
869
|
+
names = invent_names(pairs.select { |_, k| k == NAME }.map { |(_, o), _| o })
|
|
870
|
+
|
|
871
|
+
dob_dates = Set.new
|
|
872
|
+
pairs.each { |(_, o), k| (m = DATE.match(o)) && dob_dates << m[0] if k == "DATE_OF_BIRTH" }
|
|
873
|
+
MAX_BUMPS.times do |bump|
|
|
874
|
+
@dob_offset = 1 + draw("dob-offset", "", bump, 0, 364)
|
|
875
|
+
break unless Set.new(dob_dates.map { |d| a.shift_date(d, @dob_offset) }).intersect?(dob_dates)
|
|
876
|
+
end
|
|
877
|
+
ages = Set.new(pairs.select { |_, k| k == "AGE" }.map { |(_, o), _| o })
|
|
878
|
+
original_folds = Set.new(spans.map { |_, o| a.fold(o) })
|
|
879
|
+
|
|
880
|
+
out = Array.new(spans.length, "")
|
|
881
|
+
memo = {}
|
|
882
|
+
owner = {}
|
|
883
|
+
variants = {}
|
|
884
|
+
acronyms = []
|
|
885
|
+
pairs.each_with_index do |((_, original), kind), index|
|
|
886
|
+
if kind == NAME
|
|
887
|
+
out[index] = names[original]
|
|
888
|
+
next
|
|
889
|
+
end
|
|
890
|
+
if memo.key?([kind, original])
|
|
891
|
+
out[index] = memo[[kind, original]]
|
|
892
|
+
next
|
|
893
|
+
end
|
|
894
|
+
if kind == SCHOOL && original.match?(ACRONYM)
|
|
895
|
+
acronyms << index
|
|
896
|
+
next
|
|
897
|
+
end
|
|
898
|
+
start = PLUG_KINDS.include?(kind) ? variants.fetch(kind, 0) : 0
|
|
899
|
+
own = a.fold(original)
|
|
900
|
+
candidate = folded = nil
|
|
901
|
+
bump = (start...(start + MAX_BUMPS)).find do |b|
|
|
902
|
+
candidate = render(kind, original, b, memo, ages)
|
|
903
|
+
folded = a.fold(candidate)
|
|
904
|
+
next true if RUN_POOLS.key?(kind) || %w[DATE_OF_BIRTH AGE].include?(kind)
|
|
905
|
+
next true if kind == "URL" && folded == own
|
|
906
|
+
next false if original_folds.include?(folded) && folded != own
|
|
907
|
+
next false if owner.fetch([kind, folded], own) != own
|
|
908
|
+
next false if folded == own
|
|
909
|
+
|
|
910
|
+
true
|
|
911
|
+
end
|
|
912
|
+
raise AdlibPoolExhausted, "no distinct #{kind} stand-in left for this document" if bump.nil?
|
|
913
|
+
|
|
914
|
+
variants[kind] = bump + 1 if PLUG_KINDS.include?(kind)
|
|
915
|
+
owner[[kind, folded]] = own
|
|
916
|
+
memo[[kind, original]] = out[index] = candidate
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
schools = memo.select { |(k, _), _| k == SCHOOL }.map { |(_, o), v| [o, v] }
|
|
920
|
+
acronyms.each do |index|
|
|
921
|
+
original = spans[index][1]
|
|
922
|
+
unless memo.key?([SCHOOL, original])
|
|
923
|
+
hit = schools.find { |full, stand_in| a.acronym(full) == original && a.acronym(stand_in) != original }
|
|
924
|
+
memo[[SCHOOL, original]] = hit ? a.acronym(hit[1]) : scramble(original, 0)
|
|
925
|
+
end
|
|
926
|
+
out[index] = memo[[SCHOOL, original]]
|
|
927
|
+
end
|
|
928
|
+
Invented.new(out, @tokens.dup)
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
module Vicary
|
|
935
|
+
RENDER_PLACEHOLDER = "placeholder"
|
|
936
|
+
RENDER_ADLIB = "adlib"
|
|
937
|
+
DEFAULT_RENDER = RENDER_PLACEHOLDER
|
|
938
|
+
RENDER_ENV_VAR = "VICARY_RENDER"
|
|
939
|
+
ADLIB_SEED_ENV_VAR = "VICARY_ADLIB_SEED"
|
|
940
|
+
RENDER_ALIASES = {
|
|
941
|
+
RENDER_PLACEHOLDER => Set.new(%w[placeholder placeholders default]),
|
|
942
|
+
RENDER_ADLIB => Set.new(%w[adlib invented names])
|
|
943
|
+
}.freeze
|
|
944
|
+
|
|
945
|
+
# Kinds whose trailing bare apostrophe is a possessive adlib must re-form.
|
|
946
|
+
ADLIB_POSSESSIVE_KINDS = Set.new(%w[NAME PERSON SCHOOL ORGANIZATION LOCATION]).freeze
|
|
947
|
+
|
|
948
|
+
# A placeholder pass rendered as adlib. `restore_map` is `{stand-in: original}`,
|
|
949
|
+
# `adlib_map` its inverse, `adlib_spans` `[[RedactionSpan, original], ...]`,
|
|
950
|
+
# `adlib_tokens` `{invented token: original token}`. The maps are the
|
|
951
|
+
# de-identification key: never log them.
|
|
952
|
+
AdlibResult = Struct.new(:text, :restore_map, :adlib_map, :adlib_spans, :adlib_tokens,
|
|
953
|
+
keyword_init: true) do
|
|
954
|
+
def spans = adlib_spans.map(&:first)
|
|
955
|
+
|
|
956
|
+
def original
|
|
957
|
+
out = +""
|
|
958
|
+
prev = 0
|
|
959
|
+
adlib_spans.each do |span, source|
|
|
960
|
+
out << text[prev...span.new_start] << source
|
|
961
|
+
prev = span.new_end
|
|
962
|
+
end
|
|
963
|
+
out << text[prev..].to_s
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def inspect = "#<Vicary::AdlibResult text=#{text.inspect}>"
|
|
967
|
+
alias_method :to_s, :inspect
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
class << self
|
|
971
|
+
# How redacted spans are written: call-site value, then `VICARY_RENDER`,
|
|
972
|
+
# then placeholders. An unrecognised value falls back to placeholders.
|
|
973
|
+
def render_mode(value = nil)
|
|
974
|
+
raw = (value || ENV[RENDER_ENV_VAR] || "").to_s.strip.downcase
|
|
975
|
+
return DEFAULT_RENDER if raw.empty?
|
|
976
|
+
|
|
977
|
+
RENDER_ALIASES.each { |mode, aliases| return mode if aliases.include?(raw) }
|
|
978
|
+
warn "Unrecognised #{RENDER_ENV_VAR} value #{raw.inspect}; rendering #{DEFAULT_RENDER.inspect}."
|
|
979
|
+
DEFAULT_RENDER
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# The adlib seed: call-site value, then `VICARY_ADLIB_SEED`, then "0".
|
|
983
|
+
def resolve_adlib_seed(value = nil)
|
|
984
|
+
return value.to_s.strip if !value.nil? && !value.to_s.strip.empty?
|
|
985
|
+
|
|
986
|
+
env = ENV.fetch(ADLIB_SEED_ENV_VAR, "").strip
|
|
987
|
+
env.empty? ? "0" : env
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# Rewrite a numbered placeholder result with invented values (see {Adlib}).
|
|
991
|
+
# `original` is the text `masked` was redacted from. Raises ArgumentError when
|
|
992
|
+
# `masked` carries placeholders `restore_map` cannot place.
|
|
993
|
+
def render_adlib(masked, restore_map, original, seed: 0, identity: nil)
|
|
994
|
+
spans = Spans.derive(masked, restore_map)
|
|
995
|
+
if spans.empty?
|
|
996
|
+
raise ArgumentError, "adlib needs a numbered restore map to render over" if masked.match?(Spans::PLACEHOLDER)
|
|
997
|
+
|
|
998
|
+
return AdlibResult.new(text: masked, restore_map: restore_map.dup, adlib_map: {},
|
|
999
|
+
adlib_spans: [], adlib_tokens: {})
|
|
1000
|
+
end
|
|
1001
|
+
kinded = spans.map do |span|
|
|
1002
|
+
token = masked[span.new_start...span.new_end]
|
|
1003
|
+
[Placeholders.kind_of(token), restore_map.fetch(token)]
|
|
1004
|
+
end
|
|
1005
|
+
spans.each_with_index do |span, i|
|
|
1006
|
+
after = masked[span.new_end, 2].to_s
|
|
1007
|
+
next unless ADLIB_POSSESSIVE_KINDS.include?(kinded[i][0]) && ["'", "’"].include?(after[0, 1]) &&
|
|
1008
|
+
!Adlib.py_alpha?(after[1, 1].to_s) && original[span.orig_end, 1] == after[0, 1]
|
|
1009
|
+
|
|
1010
|
+
spans[i] = RedactionSpan.new(span.orig_start, span.orig_end + 1, span.new_start, span.new_end + 1)
|
|
1011
|
+
kinded[i] = [kinded[i][0], kinded[i][1] + after[0, 1]]
|
|
1012
|
+
end
|
|
1013
|
+
identity_values = []
|
|
1014
|
+
if identity
|
|
1015
|
+
extra = identity.respond_to?(:extra_names) ? identity.extra_names.to_a : []
|
|
1016
|
+
identity_values = [identity.first_name, identity.last_name, identity.school_name, *extra]
|
|
1017
|
+
.reject { |v| v.nil? || v.empty? }
|
|
1018
|
+
end
|
|
1019
|
+
invented = Adlib.invent(
|
|
1020
|
+
kinded, document: original, seed: seed, stop_words: Lexicon.stop_words,
|
|
1021
|
+
given_name: ->(t) { Gazetteer.common_given_name?(t) },
|
|
1022
|
+
notable: ->(n) { Gazetteer.notable?(n) }, identity: identity_values
|
|
1023
|
+
)
|
|
1024
|
+
out = +""
|
|
1025
|
+
rendered = []
|
|
1026
|
+
restore = {}
|
|
1027
|
+
adlib_map = {}
|
|
1028
|
+
prev = 0
|
|
1029
|
+
spans.zip(kinded, invented.stand_ins).each do |span, (_, source), stand_in|
|
|
1030
|
+
out << masked[prev...span.new_start]
|
|
1031
|
+
new_start = out.length
|
|
1032
|
+
out << stand_in
|
|
1033
|
+
rendered << [RedactionSpan.new(span.orig_start, span.orig_end, new_start, new_start + stand_in.length),
|
|
1034
|
+
source]
|
|
1035
|
+
restore[stand_in] = source unless restore.key?(stand_in)
|
|
1036
|
+
adlib_map[source] = stand_in unless adlib_map.key?(source)
|
|
1037
|
+
prev = span.new_end
|
|
1038
|
+
end
|
|
1039
|
+
out << masked[prev..].to_s
|
|
1040
|
+
AdlibResult.new(text: out, restore_map: restore, adlib_map: adlib_map, adlib_spans: rendered,
|
|
1041
|
+
adlib_tokens: invented.tokens)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def restore_adlib(text, restore_map, token_map = nil) = Adlib.restore_adlib(text, restore_map, token_map)
|
|
1045
|
+
end
|
|
1046
|
+
end
|