gov_codes 0.1.0 → 0.1.2

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +15 -1
  3. data/.tool-versions +1 -1
  4. data/CHANGELOG.md +57 -2
  5. data/README.md +223 -22
  6. data/Rakefile +7 -1
  7. data/checksums/gov_codes-0.1.0.gem.sha512 +1 -0
  8. data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
  9. data/lib/gov_codes/afsc/enlisted.rb +134 -24
  10. data/lib/gov_codes/afsc/officer.rb +141 -25
  11. data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +2726 -0
  12. data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/ri.yml +369 -0
  13. data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/officer.yml +2393 -0
  14. data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/ri.yml +193 -0
  15. data/lib/gov_codes/afsc/releases.rb +219 -0
  16. data/lib/gov_codes/afsc/releases.yml +11 -0
  17. data/lib/gov_codes/afsc/ri.rb +207 -0
  18. data/lib/gov_codes/afsc.rb +35 -3
  19. data/lib/gov_codes/dafecd/index_builder.rb +416 -0
  20. data/lib/gov_codes/dafecd/patterns.rb +50 -0
  21. data/lib/gov_codes/dafecd/publication.rb +252 -0
  22. data/lib/gov_codes/dafecd/record_splitter.rb +77 -0
  23. data/lib/gov_codes/dafecd/ri_sdi/change_date.rb +63 -0
  24. data/lib/gov_codes/dafecd/ri_sdi/config.rb +137 -0
  25. data/lib/gov_codes/dafecd/ri_sdi/index_builder.rb +265 -0
  26. data/lib/gov_codes/dafecd/ri_sdi/ri_list_parser.rb +125 -0
  27. data/lib/gov_codes/dafecd/ri_sdi/sdi_card_parser.rb +151 -0
  28. data/lib/gov_codes/dafecd/ri_sdi/sdi_section_splitter.rb +77 -0
  29. data/lib/gov_codes/dafecd/ri_sdi/section_slicer.rb +45 -0
  30. data/lib/gov_codes/dafecd/ri_sdi/title.rb +76 -0
  31. data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafecd.yml +124 -0
  32. data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafocd.yml +78 -0
  33. data/lib/gov_codes/dafecd/shredout_acronyms.rb +47 -0
  34. data/lib/gov_codes/dafecd/shredout_degluer.rb +73 -0
  35. data/lib/gov_codes/dafecd/shredout_overrides/dafecd.yml +27 -0
  36. data/lib/gov_codes/dafecd/shredout_overrides/dafocd.yml +27 -0
  37. data/lib/gov_codes/dafecd/shredout_parser.rb +75 -0
  38. data/lib/gov_codes/dafecd/specialty_parser.rb +200 -0
  39. data/lib/gov_codes/dafecd/text.rb +48 -0
  40. data/lib/gov_codes/dafecd/title_degluer.rb +67 -0
  41. data/lib/gov_codes/dafecd/title_overrides/dafocd.yml +141 -0
  42. data/lib/gov_codes/dafecd/title_overrides.yml +142 -0
  43. data/lib/gov_codes/data_loader.rb +49 -36
  44. data/lib/gov_codes/version.rb +1 -1
  45. metadata +36 -4
  46. data/lib/gov_codes/afsc/enlisted.yml +0 -725
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+ require_relative "publication"
5
+ require_relative "record_splitter"
6
+ require_relative "specialty_parser"
7
+ require_relative "shredout_parser"
8
+ require_relative "shredout_acronyms"
9
+ require_relative "title_degluer"
10
+ require_relative "shredout_degluer"
11
+
12
+ module GovCodes
13
+ module Dafecd
14
+ # Assembles parsed DAFECD records into a specialty-keyed index.
15
+ #
16
+ # Verification gate (DEC-003) — read this before trusting "0 unverified".
17
+ # The gate confirms that every value emitted into the index appears verbatim
18
+ # in the source text. For the CURRENT purely-deterministic extraction every
19
+ # emitted code is a verbatim slice of the source, so the gate is guaranteed
20
+ # to pass — "0 unverified" is a tautology here, NOT evidence of correctness.
21
+ # The gate earns its keep as a REGRESSION GUARD for future value-transforming
22
+ # steps (notably the C.2 title de-gluer, whose invariant is "the de-glued
23
+ # title equals its source with only spaces changed"). Such a transform
24
+ # registers the values it emits via the #emitted_values_to_verify hook, and
25
+ # the gate flags any that are not grounded in the source.
26
+ #
27
+ # Drops are surfaced, never hidden: any record that carries a specialty
28
+ # signal (a CEM code) but yields zero parsed ladder codes is collected in
29
+ # #dropped_records with a reason. The invariant
30
+ # records_split == index.size + merged_count + dropped_records.size holds.
31
+ #
32
+ # Title de-gluing: a TitleDegluer supplies verified clean titles (see
33
+ # TitleDegluer). Each applied override is checked against the raw source
34
+ # title via the de-gluing invariant (spacing/case only); drift lands in
35
+ # #unverified_titles and must fail the build. Specialties without an override
36
+ # keep the auto-titlecased name and are listed in #specialties_needing_deglue.
37
+ class IndexBuilder
38
+ # A specialty acronym is a trailing parenthetical whose sole token is an
39
+ # uppercase abbreviation, e.g. "... (TACP)" -> TACP. Only trailing parens
40
+ # qualify; a mid-title paren ("... (SERE) Specialist") is prose, not the
41
+ # specialty's acronym.
42
+ ACRONYM_PATTERN = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
43
+
44
+ def initialize(source_text, publication: Publication.dafecd, degluer: TitleDegluer.empty,
45
+ shredout_degluer: ShredoutDegluer.empty)
46
+ @source_text = source_text
47
+ @publication = publication
48
+ @degluer = degluer
49
+ @shredout_degluer = shredout_degluer
50
+ @index = nil
51
+ @unverified_codes = nil
52
+ end
53
+
54
+ # Specialties whose trailing parenthetical is a phrase-abbreviation, not a
55
+ # specialty acronym (per-publication; see Publication#acronym_exclusions).
56
+ def acronym_exclusions
57
+ @publication.acronym_exclusions
58
+ end
59
+
60
+ # @return [Hash{Symbol=>Hash}] X-form specialty => index entry
61
+ def build
62
+ @index = {}
63
+ @unverified_codes = []
64
+ @unverified_titles = []
65
+ @unverified_acronyms = []
66
+ @unverified_shredouts = []
67
+ @needs_deglue = []
68
+ @dropped_records = []
69
+ @merge_conflicts = []
70
+ @merged_count = 0
71
+
72
+ records = RecordSplitter.new(@source_text, publication: @publication).records
73
+ @records_split = records.size
74
+
75
+ records.each do |record|
76
+ header = SpecialtyParser.new(record, publication: @publication).parse
77
+ specialty = header[:specialty]
78
+
79
+ if specialty.nil?
80
+ @dropped_records << drop_descriptor(record)
81
+ next
82
+ end
83
+
84
+ shredouts = ShredoutParser.new(record, publication: @publication).parse
85
+ entry = build_entry(header, shredouts, record)
86
+ @merged_count += 1 if @index.key?(specialty)
87
+ merge_entry(specialty, entry)
88
+ end
89
+
90
+ @index.each do |specialty, entry|
91
+ apply_override(specialty, entry)
92
+ capture_acronym(specialty, entry)
93
+ verify(entry)
94
+ end
95
+ apply_shredout_overrides
96
+ @index
97
+ end
98
+
99
+ # Values emitted into the index that could NOT be found verbatim in the
100
+ # source. Empty by construction for the current deterministic extraction;
101
+ # non-empty means a transform (e.g. the de-gluer) emitted an ungrounded
102
+ # value. See the class docstring.
103
+ def unverified_codes
104
+ build if @unverified_codes.nil?
105
+ @unverified_codes
106
+ end
107
+
108
+ # Applied title overrides that violate the de-gluing invariant (their
109
+ # letters/digits/punctuation differ from the raw source title), each a
110
+ # Hash of {specialty:, applied:, raw_title:, reason:}. Non-empty means a
111
+ # stale/incorrect override; the build must fail. See #unverified?.
112
+ def unverified_titles
113
+ build if @unverified_titles.nil?
114
+ @unverified_titles
115
+ end
116
+
117
+ # Specialties that have an auto-titlecased name but no verified override
118
+ # (i.e. still need de-gluing). Should be empty once every specialty is
119
+ # covered; kept for robustness across future entity types.
120
+ def specialties_needing_deglue
121
+ build if @needs_deglue.nil?
122
+ @needs_deglue
123
+ end
124
+
125
+ # Emitted :acronym values that could NOT be found (whitespace/case
126
+ # tolerant) in their specialty's raw source title. Non-empty means a
127
+ # drifting/hallucinated acronym; the build must fail. See #unverified?.
128
+ def unverified_acronyms
129
+ build if @unverified_acronyms.nil?
130
+ @unverified_acronyms
131
+ end
132
+
133
+ # Applied shredout-value overrides that violate the de-gluing invariant
134
+ # (their letters/digits/punctuation differ from the raw extracted value) or
135
+ # target a shredout absent from the source, each a Hash of
136
+ # {specialty:, suffix:, applied:, raw:, reason:}. Non-empty means a
137
+ # stale/incorrect override; the build must fail. See #unverified?.
138
+ def unverified_shredouts
139
+ build if @unverified_shredouts.nil?
140
+ @unverified_shredouts
141
+ end
142
+
143
+ # True if any emitted value (code, applied title, acronym, or shredout
144
+ # value) failed verification. The CLI must fail the build when this is true.
145
+ def unverified?
146
+ unverified_codes.any? || unverified_titles.any? || unverified_acronyms.any? ||
147
+ unverified_shredouts.any?
148
+ end
149
+
150
+ # Records that carried a specialty signal but produced no ladder codes,
151
+ # each a Hash of {cem_code:, first_line:, reason:}. Surfaced so drops can
152
+ # never hide (C1).
153
+ def dropped_records
154
+ build if @dropped_records.nil?
155
+ @dropped_records
156
+ end
157
+
158
+ # Number of records the splitter produced.
159
+ def records_split
160
+ build if @records_split.nil?
161
+ @records_split
162
+ end
163
+
164
+ # Number of records that merged into an already-seen specialty.
165
+ def merged_count
166
+ build if @merged_count.nil?
167
+ @merged_count
168
+ end
169
+
170
+ # Merge collisions where an incoming record supplied a DIFFERENT title for
171
+ # a level digit already present, each a Hash of {specialty:, level:, kept:,
172
+ # discarded:, code:}. The existing (first-seen) title always wins; the
173
+ # discarded title is recorded here so the collision is visible in the CLI
174
+ # reconciliation rather than silently dropped (I1 layer b).
175
+ def merge_conflicts
176
+ build if @merge_conflicts.nil?
177
+ @merge_conflicts
178
+ end
179
+
180
+ # The verification-gate predicate: does +value+ appear verbatim in source?
181
+ def verified?(value)
182
+ @source_text.include?(value.to_s)
183
+ end
184
+
185
+ # Specialties whose title could not be extracted (for the coverage report).
186
+ def specialties_missing_title
187
+ build if @index.nil?
188
+ @index.select { |_, e| e[:name].nil? || e[:name].empty? }.keys
189
+ end
190
+
191
+ # Specialties with no shredout table (for the coverage report). This is a
192
+ # normal condition for many specialties, not an error.
193
+ def specialties_without_shredouts
194
+ build if @index.nil?
195
+ @index.select { |_, e| e[:shredouts].empty? }.keys
196
+ end
197
+
198
+ # Specialties whose title is flagged as a probable pdf-reader glue artifact
199
+ # (heuristic), deferred to the C.2 despacer.
200
+ def glued_titles
201
+ build if @index.nil?
202
+ @index.select { |_, e| e[:glued_title] }.transform_values { |e| e[:name] }
203
+ end
204
+
205
+ # Full title inventory for the de-gluing step: every specialty with its
206
+ # current (title-cased) name and the raw pre-titlecase source title.
207
+ # @return [Array<Hash>] {specialty:, name:, raw_title:, glued:}
208
+ def title_inventory
209
+ build if @index.nil?
210
+ @index.sort_by { |k, _| k.to_s }.map do |specialty, e|
211
+ {specialty: specialty, name: e[:name], raw_title: e[:raw_title], glued: e[:glued_title]}
212
+ end
213
+ end
214
+
215
+ private
216
+
217
+ # Describe a dropped record for the accounting report.
218
+ def drop_descriptor(record)
219
+ cem = @publication.cem && record[@publication.cem, 1]
220
+ first_line = record.lines.map(&:strip).reject(&:empty?).first
221
+ reason =
222
+ if cem
223
+ "CEM code present but no ladder AFSC lines (career-field CEM manager)"
224
+ else
225
+ "no specialty code (no ladder, bare, or CEM AFSC lines)"
226
+ end
227
+ {cem_code: cem, first_line: first_line, reason: reason}
228
+ end
229
+
230
+ def build_entry(header, shredouts, record)
231
+ entry = {
232
+ name: header[:name],
233
+ raw_title: header[:raw_title],
234
+ career_field: header[:career_field],
235
+ cem_code: header[:cem_code],
236
+ bare_code: header[:bare_code],
237
+ changed_date: header[:changed_date],
238
+ shredouts: shredouts,
239
+ glued_title: header[:glued_title]
240
+ }.merge(@publication.levels_key => header[@publication.levels_key])
241
+ attach_shredout_acronyms(entry, header[:specialty], shredouts, record)
242
+ entry
243
+ end
244
+
245
+ # Attach (and verify) shredout-level acronyms for publications that capture
246
+ # them (officer). Sourced from the shredout table values and the in-record
247
+ # numbered enumeration; kept only when non-empty.
248
+ def attach_shredout_acronyms(entry, specialty, shredouts, record)
249
+ return unless @publication.captures_shredout_acronyms?
250
+ acronyms = ShredoutAcronyms.from_table(shredouts)
251
+ .merge(ShredoutAcronyms.from_enumeration(record, family_of(specialty)))
252
+ return if acronyms.empty?
253
+ verify_shredout_acronyms(acronyms, record)
254
+ entry[:shredout_acronyms] = acronyms
255
+ end
256
+
257
+ # The 3-char specialty family for enumeration scoping ("19ZX" -> "19Z").
258
+ def family_of(specialty)
259
+ str = specialty.to_s
260
+ str.end_with?("X") ? str.delete_suffix("X") : str
261
+ end
262
+
263
+ # Merge a re-encountered specialty (e.g. a record split by a page break)
264
+ # rather than silently overwriting it.
265
+ def merge_entry(specialty, entry)
266
+ existing = @index[specialty]
267
+ if existing.nil?
268
+ @index[specialty] = entry
269
+ return
270
+ end
271
+
272
+ levels_key = @publication.levels_key
273
+ existing[:name] ||= entry[:name]
274
+ existing[:raw_title] ||= entry[:raw_title]
275
+ existing[:career_field] ||= entry[:career_field]
276
+ existing[:cem_code] ||= entry[:cem_code]
277
+ existing[:bare_code] ||= entry[:bare_code]
278
+ existing[:changed_date] ||= entry[:changed_date]
279
+ record_level_conflicts(specialty, existing[levels_key], entry[levels_key])
280
+ existing[levels_key] = entry[levels_key].merge(existing[levels_key])
281
+ existing[:shredouts] = entry[:shredouts].merge(existing[:shredouts])
282
+ if entry[:shredout_acronyms]
283
+ existing[:shredout_acronyms] = entry[:shredout_acronyms].merge(existing[:shredout_acronyms] || {})
284
+ end
285
+ existing[:glued_title] ||= entry[:glued_title]
286
+ end
287
+
288
+ # Record any level digit the incoming record re-titles differently from the
289
+ # already-present record. The existing title is kept (the merge below is
290
+ # existing-wins); the disagreement is surfaced so a re-titling can never
291
+ # pass silently (I1 layer b).
292
+ def record_level_conflicts(specialty, existing_levels, incoming_levels)
293
+ incoming_levels.each do |digit, incoming|
294
+ current = existing_levels[digit]
295
+ next if current.nil? || current[:title] == incoming[:title]
296
+ @merge_conflicts << {
297
+ specialty: specialty,
298
+ level: digit,
299
+ kept: current[:title],
300
+ discarded: incoming[:title],
301
+ code: incoming[:code]
302
+ }
303
+ end
304
+ end
305
+
306
+ # Apply a verified de-glued title override, enforcing the invariant that an
307
+ # override differs from the raw source title only in spacing/case. Drift is
308
+ # recorded (never silently applied); a missing override keeps the auto
309
+ # title and is flagged for de-gluing.
310
+ def apply_override(specialty, entry)
311
+ override = @degluer.override_for(specialty)
312
+
313
+ if override.nil?
314
+ @needs_deglue << specialty if entry[:name]
315
+ return
316
+ end
317
+
318
+ if TitleDegluer.matches_source?(override, entry[:raw_title])
319
+ entry[:name] = override
320
+ else
321
+ @unverified_titles << {
322
+ specialty: specialty,
323
+ applied: override,
324
+ raw_title: entry[:raw_title],
325
+ reason: "override differs from source title by more than spacing/case"
326
+ }
327
+ end
328
+ end
329
+
330
+ # Apply verified de-glued shredout VALUES, enforcing the same invariant as
331
+ # titles: an override may differ from the raw extracted value only in
332
+ # spacing/case. Every declared override is checked (not just the applied
333
+ # ones), so an override targeting a specialty or suffix absent from the
334
+ # source is surfaced as unverified rather than silently ignored. Shredout
335
+ # acronyms were already lifted from the raw values (during build_entry), so
336
+ # de-gluing the value here leaves them untouched.
337
+ def apply_shredout_overrides
338
+ @shredout_degluer.each_override do |specialty, suffix, clean|
339
+ entry = @index[specialty]
340
+ raw = entry && entry[:shredouts][suffix]
341
+
342
+ if raw.nil?
343
+ @unverified_shredouts << {
344
+ specialty: specialty, suffix: suffix, applied: clean, raw: raw,
345
+ reason: entry.nil? ?
346
+ "override targets a specialty absent from the index" :
347
+ "override targets a shredout suffix absent from the source"
348
+ }
349
+ elsif TitleDegluer.matches_source?(clean, raw)
350
+ entry[:shredouts][suffix] = clean
351
+ else
352
+ @unverified_shredouts << {
353
+ specialty: specialty, suffix: suffix, applied: clean, raw: raw,
354
+ reason: "override differs from source shredout by more than spacing/case"
355
+ }
356
+ end
357
+ end
358
+ end
359
+
360
+ # Capture the specialty acronym from the (de-glued) name: a trailing
361
+ # parenthetical uppercase token. Excluded specialties (phrase
362
+ # abbreviations, not specialty acronyms) never receive an :acronym. The
363
+ # name is left unchanged; it retains the parenthetical.
364
+ def capture_acronym(specialty, entry)
365
+ return if acronym_exclusions.include?(specialty)
366
+ name = entry[:name]
367
+ return if name.nil?
368
+
369
+ match = name.match(ACRONYM_PATTERN)
370
+ entry[:acronym] = match[1] if match
371
+ end
372
+
373
+ def verify(entry)
374
+ entry[@publication.levels_key].each_value do |level|
375
+ @unverified_codes << level[:code] unless verified?(level[:code])
376
+ end
377
+ cem = entry[:cem_code]
378
+ @unverified_codes << cem if cem && !verified?(cem)
379
+ bare = entry[:bare_code]
380
+ @unverified_codes << bare if bare && !verified?(bare)
381
+ emitted_values_to_verify(entry).each do |value|
382
+ @unverified_codes << value unless verified?(value)
383
+ end
384
+ verify_acronym(entry)
385
+ end
386
+
387
+ # The acronym gate (DEC-003): an emitted :acronym must appear as a
388
+ # parenthetical `(ACRONYM)` in the specialty's raw source title -- not
389
+ # merely as some substring of it -- under the same whitespace/case
390
+ # tolerance the de-glue gate uses. A drifting/absent acronym is unverified.
391
+ def verify_acronym(entry)
392
+ acronym = entry[:acronym]
393
+ return if acronym.nil?
394
+
395
+ source = TitleDegluer.norm(entry[:raw_title])
396
+ @unverified_acronyms << acronym unless source.include?(TitleDegluer.norm("(#{acronym})"))
397
+ end
398
+
399
+ # Each emitted shredout acronym must appear as a parenthetical `(ACRONYM)`
400
+ # verbatim (whitespace/case tolerant) in the record's own source text.
401
+ def verify_shredout_acronyms(acronyms, record)
402
+ source = TitleDegluer.norm(record)
403
+ acronyms.each_value do |acronym|
404
+ @unverified_acronyms << acronym unless source.include?(TitleDegluer.norm("(#{acronym})"))
405
+ end
406
+ end
407
+
408
+ # Hook for future value-transforming steps (e.g. the C.2 title de-gluer):
409
+ # return the additional values the transform emits into this entry so the
410
+ # gate can confirm each is grounded in the source. Default: none.
411
+ def emitted_values_to_verify(entry)
412
+ []
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GovCodes
4
+ module Dafecd
5
+ # Shared line-anchor patterns used by both RecordSplitter (to detect record
6
+ # boundaries) and SpecialtyParser (to extract the ladder). Kept in one place
7
+ # so the two never drift apart.
8
+ module Patterns
9
+ # Skill-ladder line. Captures the concrete 5-char AFSC (group 1) and the
10
+ # skill-level word (group 2). Tolerant of the DAFECD's formatting quirks:
11
+ # * an OPTIONAL leading "AFSC" prefix — pdf-reader sometimes shifts the
12
+ # "AFSC" token to the end of the previous line, leaving a bare code at
13
+ # line start (e.g. "1A194, SuperintendentAFSC"),
14
+ # * an optional TRAILING "AFSC" (that shifted token),
15
+ # * an optional "*" restriction marker on the code,
16
+ # * an optional alternate code, e.g. "3E471 or 3E471A",
17
+ # * an optional comma,
18
+ # * an optional specialty-specific qualifier before the level word,
19
+ # e.g. "Cryptologic Intelligence Superintendent",
20
+ # * a wrapped "Senior Enlisted" (Leader on the following line),
21
+ # * an optional trailing acronym, e.g. "Senior Enlisted Leader (SEL)".
22
+ # The code must be at line start and the level word must END the line;
23
+ # together these reject prose mentions such as "possession ofAFSC 1Z331,
24
+ # which ...". (Prefix-glue like "LeaderAFSC 1A178" is split onto its own
25
+ # line by Text.split_glued_afsc before this pattern is applied.)
26
+ LADDER = /
27
+ ^\s*(?:AFSC\s+)?(\d[A-Z]\d\d\d)\*?
28
+ (?:\s+or\s+\d[A-Z0-9]+)?
29
+ ,?\s*
30
+ (?:[A-Za-z][A-Za-z\/]*\s+){0,3}
31
+ (Helper|Apprentice|Journeyman|Craftsman|
32
+ Superintendent|Senior\s+Enlisted(?:\s+Leader)?|Entry)
33
+ (?:\s*\([A-Z]{2,6}\))?
34
+ (?:\s*AFSC)?
35
+ \s*$
36
+ /x
37
+
38
+ # CEM (Chief Enlisted Manager) code line, e.g. "CEM Code 1A100*".
39
+ CEM = /^\s*CEM Code\s+(\d[A-Z]\d00)\*?/
40
+
41
+ # Decorative glyphs pdf-reader lifts from symbol fonts and scatters through
42
+ # the text: Private Use Area bullets, black/white stars, and common
43
+ # bullets. They are never part of the data and are stripped before parsing.
44
+ DECORATIVE = /[\u{E000}-\u{F8FF}★☆•●▪■⁃∙]/
45
+
46
+ # Unicode dashes the directory uses, normalized to ASCII "-".
47
+ UNICODE_DASHES = /[‐-―−]/
48
+ end
49
+ end
50
+ end