vicary 0.2.19 → 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.
@@ -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