gov_codes 0.1.1 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +13 -1
  3. data/.tool-versions +1 -1
  4. data/CHANGELOG.md +31 -26
  5. data/README.md +180 -25
  6. data/Rakefile +7 -2
  7. data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
  8. data/lib/gov_codes/afsc/enlisted.rb +111 -80
  9. data/lib/gov_codes/afsc/officer.rb +121 -65
  10. data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +2726 -0
  11. data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/ri.yml +369 -0
  12. data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/officer.yml +2393 -0
  13. data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/ri.yml +193 -0
  14. data/lib/gov_codes/afsc/releases.rb +219 -0
  15. data/lib/gov_codes/afsc/releases.yml +11 -0
  16. data/lib/gov_codes/afsc/ri.rb +161 -126
  17. data/lib/gov_codes/afsc.rb +29 -8
  18. data/lib/gov_codes/dafecd/index_builder.rb +416 -0
  19. data/lib/gov_codes/dafecd/patterns.rb +50 -0
  20. data/lib/gov_codes/dafecd/publication.rb +252 -0
  21. data/lib/gov_codes/dafecd/record_splitter.rb +77 -0
  22. data/lib/gov_codes/dafecd/ri_sdi/change_date.rb +63 -0
  23. data/lib/gov_codes/dafecd/ri_sdi/config.rb +137 -0
  24. data/lib/gov_codes/dafecd/ri_sdi/index_builder.rb +265 -0
  25. data/lib/gov_codes/dafecd/ri_sdi/ri_list_parser.rb +125 -0
  26. data/lib/gov_codes/dafecd/ri_sdi/sdi_card_parser.rb +151 -0
  27. data/lib/gov_codes/dafecd/ri_sdi/sdi_section_splitter.rb +77 -0
  28. data/lib/gov_codes/dafecd/ri_sdi/section_slicer.rb +45 -0
  29. data/lib/gov_codes/dafecd/ri_sdi/title.rb +76 -0
  30. data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafecd.yml +124 -0
  31. data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafocd.yml +78 -0
  32. data/lib/gov_codes/dafecd/shredout_acronyms.rb +47 -0
  33. data/lib/gov_codes/dafecd/shredout_degluer.rb +73 -0
  34. data/lib/gov_codes/dafecd/shredout_overrides/dafecd.yml +27 -0
  35. data/lib/gov_codes/dafecd/shredout_overrides/dafocd.yml +27 -0
  36. data/lib/gov_codes/dafecd/shredout_parser.rb +75 -0
  37. data/lib/gov_codes/dafecd/specialty_parser.rb +200 -0
  38. data/lib/gov_codes/dafecd/text.rb +48 -0
  39. data/lib/gov_codes/dafecd/title_degluer.rb +67 -0
  40. data/lib/gov_codes/dafecd/title_overrides/dafocd.yml +141 -0
  41. data/lib/gov_codes/dafecd/title_overrides.yml +142 -0
  42. data/lib/gov_codes/data_loader.rb +19 -0
  43. data/lib/gov_codes/version.rb +1 -1
  44. metadata +34 -6
  45. data/lib/gov_codes/afsc/enlisted.yml +0 -532
  46. data/lib/gov_codes/afsc/officer.yml +0 -1072
  47. data/lib/gov_codes/afsc/ri.yml +0 -237
@@ -0,0 +1,265 @@
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 "config"
11
+ require_relative "section_slicer"
12
+ require_relative "sdi_section_splitter"
13
+ require_relative "sdi_card_parser"
14
+ require_relative "ri_list_parser"
15
+
16
+ module GovCodes
17
+ module Dafecd
18
+ module RiSdi
19
+ # Assembles the RI and SDI records of one publication into a single
20
+ # code-keyed index and surfaces the reconciliation data the CLI reports.
21
+ #
22
+ # Every section named by the Config is sliced out and parsed by the format
23
+ # it uses: SDI sections by the SdiSectionSplitter + SdiCardParser (plus the
24
+ # one embedded CEM ladder record, parsed by the AFSC SpecialtyParser and
25
+ # keyed by its CEM code), RI sections by the RiListParser.
26
+ #
27
+ # Verification gate: every emitted code must appear verbatim in the source,
28
+ # and every emitted acronym must appear as a "(ACRONYM)" parenthetical in
29
+ # its record's raw title (whitespace/dash/case tolerant). Titles are
30
+ # extracted verbatim (only sanitized) and then de-glued via human-verified
31
+ # overrides supplied by an injected TitleDegluer: each applied override is
32
+ # gated against the raw source title (spacing/case only), so drift lands in
33
+ # #unverified_titles and fails the build; a code with no override keeps its
34
+ # verbatim title and is reported in #codes_needing_deglue (not an error).
35
+ # #unverified? must be false before anything is written.
36
+ #
37
+ # Acronyms are captured from a title's trailing parenthetical, EXCEPT for
38
+ # the per-publication Config#acronym_exclusions (organization/sub-phrase
39
+ # abbreviations). Every candidate — shipped or excluded — is reported via
40
+ # #acronym_candidates for review.
41
+ class IndexBuilder
42
+ # A trailing parenthetical whose sole token is an uppercase abbreviation.
43
+ # Mirrors Dafecd::IndexBuilder::ACRONYM_PATTERN.
44
+ ACRONYM_PATTERN = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
45
+
46
+ UNICODE_DASHES = Patterns::UNICODE_DASHES
47
+
48
+ attr_reader :index, :dropped_records, :collisions, :acronym_candidates,
49
+ :unverified_codes, :unverified_titles, :unverified_acronyms,
50
+ :codes_needing_deglue, :section_counts, :section_codes, :sequence_numbers
51
+
52
+ def initialize(full_text, config: Config.dafecd, degluer: TitleDegluer.empty)
53
+ @full_text = full_text
54
+ @config = config
55
+ @degluer = degluer
56
+ end
57
+
58
+ def build
59
+ @index = {}
60
+ @dropped_records = []
61
+ @collisions = []
62
+ @acronym_candidates = []
63
+ @unverified_codes = []
64
+ @unverified_titles = []
65
+ @unverified_acronyms = []
66
+ @codes_needing_deglue = []
67
+ @section_counts = Hash.new(0)
68
+ @section_codes = Hash.new { |h, k| h[k] = [] }
69
+ @sequence_numbers = Hash.new { |h, k| h[k] = [] }
70
+
71
+ SectionSlicer.new(@full_text, config: @config).sections.each do |section|
72
+ case section.kind
73
+ when :sdi then build_sdi(section)
74
+ when :ri then build_ri(section)
75
+ end
76
+ end
77
+
78
+ @index.each { |code, entry| apply_override(code, entry) }
79
+ @index.each { |code, entry| capture_acronym(code, entry) }
80
+ @index.each { |code, entry| verify(code, entry) }
81
+ @index
82
+ end
83
+
84
+ # True if any emitted value failed verification; the CLI must not write.
85
+ def unverified?
86
+ @unverified_codes.any? || @unverified_titles.any? || @unverified_acronyms.any?
87
+ end
88
+
89
+ # @return [Hash{Symbol=>Hash}] force => {present:, missing:, duplicates:}
90
+ def sequence_report
91
+ @sequence_numbers.transform_values do |numbers|
92
+ present = numbers.uniq.sort
93
+ duplicates = numbers.group_by(&:itself).select { |_, v| v.size > 1 }.keys.sort
94
+ missing = present.empty? ? [] : ((present.first..present.last).to_a - present)
95
+ {present: present, missing: missing, duplicates: duplicates}
96
+ end
97
+ end
98
+
99
+ # @return [Array<Hash>] {code:, raw_title:} in code order, for the
100
+ # governed de-glue pass (titles are NOT cleaned here).
101
+ def title_inventory
102
+ @index.sort_by { |code, _| code.to_s }.map do |code, entry|
103
+ {code: code, name: entry[:name], raw_title: entry[:raw_title], glued: entry[:glued_title]}
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ # --- SDI sections --------------------------------------------------------
110
+
111
+ def build_sdi(section)
112
+ SdiSectionSplitter.new(section.text, config: @config).records.each do |record|
113
+ entries = SdiCardParser.new(record, config: @config).entries
114
+
115
+ if entries.empty?
116
+ if record.match?(@config.cem)
117
+ entries = [cem_entry(record)]
118
+ else
119
+ note_sdi_drop(record)
120
+ next
121
+ end
122
+ end
123
+
124
+ entries.each { |entry| add(section, entry) }
125
+ end
126
+ end
127
+
128
+ # The single embedded CEM ladder record (Premier Honor Guard, 8G000),
129
+ # parsed by the AFSC pipeline and keyed by its CEM code.
130
+ def cem_entry(record)
131
+ header = SpecialtyParser.new(record, publication: Publication.dafecd).parse
132
+ shredouts = ShredoutParser.new(record, publication: Publication.dafecd).parse
133
+ {
134
+ code: header[:cem_code],
135
+ name: header[:name],
136
+ raw_title: header[:raw_title],
137
+ changed_date: header[:changed_date],
138
+ glued_title: header[:glued_title],
139
+ shredouts: shredouts
140
+ }
141
+ end
142
+
143
+ def note_sdi_drop(record)
144
+ # Detect the anchor with the SAME structural regexes the splitter/parser
145
+ # use — a standalone "SDI <code>" line OR an inline "<code>,<title>" line
146
+ # (the latter now tolerates a missing "SDI " prefix), so a future dropped
147
+ # bare-format card is logged rather than silently skipped.
148
+ anchor = record.lines.find do |line|
149
+ line.match?(@config.sdi_anchor) || line.match?(@config.sdi_inline_anchor)
150
+ end
151
+ return unless anchor
152
+ @dropped_records << {
153
+ section: :sdi,
154
+ first_line: anchor.strip,
155
+ reason: "SDI anchor produced no valid entry"
156
+ }
157
+ end
158
+
159
+ # --- RI sections ---------------------------------------------------------
160
+
161
+ def build_ri(section)
162
+ RiListParser.new(section.text, config: @config).entries.each do |entry|
163
+ @sequence_numbers[section.force] << entry[:number]
164
+ add(section, entry)
165
+ end
166
+ end
167
+
168
+ # --- Index assembly ------------------------------------------------------
169
+
170
+ def add(section, entry)
171
+ code = entry[:code].to_sym
172
+ key = [section.force, section.kind]
173
+
174
+ if @index.key?(code)
175
+ @collisions << {code: code, section: key, kept: @index[code][:name], discarded: entry[:name]}
176
+ return
177
+ end
178
+
179
+ @index[code] = build_entry(section, entry)
180
+ @section_counts[key] += 1
181
+ @section_codes[key] << code
182
+ end
183
+
184
+ def build_entry(section, entry)
185
+ shredouts = entry[:shredouts] || {}
186
+ {
187
+ force: section.force,
188
+ kind: section.kind,
189
+ name: entry[:name],
190
+ raw_title: entry[:raw_title],
191
+ changed_date: entry[:changed_date],
192
+ glued_title: entry[:glued_title],
193
+ shredouts: shredouts,
194
+ shredout_acronyms: ShredoutAcronyms.from_table(shredouts)
195
+ }
196
+ end
197
+
198
+ # --- Title de-gluing (verified overrides) --------------------------------
199
+
200
+ # Apply a verified de-glued title override to +entry[:name]+, enforcing
201
+ # the same invariant as the AFSC pipeline: an override may differ from the
202
+ # raw source title only in spacing/case (TitleDegluer.matches_source?).
203
+ # Drift (a changed letter/digit/punctuation, or a stale rename) is
204
+ # recorded in #unverified_titles and never silently applied. A code with
205
+ # no override keeps its verbatim (glued) title and is merely reported in
206
+ # #codes_needing_deglue -- unlike the AFSC titles, full override coverage
207
+ # is not required for the build to pass.
208
+ def apply_override(code, entry)
209
+ override = @degluer.override_for(code)
210
+
211
+ if override.nil?
212
+ @codes_needing_deglue << code if entry[:name]
213
+ return
214
+ end
215
+
216
+ if TitleDegluer.matches_source?(override, entry[:raw_title])
217
+ entry[:name] = override
218
+ else
219
+ @unverified_titles << {
220
+ code: code,
221
+ applied: override,
222
+ raw_title: entry[:raw_title],
223
+ reason: "override differs from source title by more than spacing/case"
224
+ }
225
+ end
226
+ end
227
+
228
+ # --- Acronyms ------------------------------------------------------------
229
+
230
+ def capture_acronym(code, entry)
231
+ name = entry[:name]
232
+ return if name.nil?
233
+ match = name.match(ACRONYM_PATTERN)
234
+ return unless match
235
+
236
+ acronym = match[1]
237
+ shipped = !@config.acronym_exclusions.include?(code)
238
+ @acronym_candidates << {code: code, acronym: acronym, name: name, shipped: shipped}
239
+ entry[:acronym] = acronym if shipped
240
+ end
241
+
242
+ # --- Verification gate ---------------------------------------------------
243
+
244
+ def verify(code, entry)
245
+ @unverified_codes << code.to_s unless @full_text.include?(code.to_s)
246
+
247
+ raw = entry[:raw_title]
248
+ if raw && !norm(@full_text).include?(norm(raw))
249
+ @unverified_titles << {code: code, raw_title: raw}
250
+ end
251
+
252
+ acronym = entry[:acronym]
253
+ if acronym && !norm(raw).include?(norm("(#{acronym})"))
254
+ @unverified_acronyms << acronym
255
+ end
256
+ end
257
+
258
+ # Whitespace-, dash-, and case-insensitive normalization for the gate.
259
+ def norm(str)
260
+ str.to_s.gsub(UNICODE_DASHES, "-").gsub(/\s+/, "").downcase
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patterns"
4
+ require_relative "../shredout_parser"
5
+ require_relative "config"
6
+ require_relative "change_date"
7
+ require_relative "title"
8
+
9
+ module GovCodes
10
+ module Dafecd
11
+ module RiSdi
12
+ # Parses a flat, numbered Reporting Identifiers (RI) list into per-code
13
+ # entries.
14
+ #
15
+ # Each top-level list item ("<n>. <code>, <title>. <date>") is one record,
16
+ # delimited by the next top-level number. The anchor tolerates the source's
17
+ # pdf-reader glue and decoration: an optional run of spaces (or none) after
18
+ # "<n>.", an optional leading decorative star, a comma OR a bare space
19
+ # before the title, and a list number glued to the code ("34.9T000").
20
+ #
21
+ # The title runs from the code to the FIRST of a change-date parenthetical
22
+ # or a sentence-ending period, and may wrap across physical lines. The
23
+ # officer directory omits the comma and always ends the title at the first
24
+ # period ("90G0 General Officer. Use this identifier ...").
25
+ #
26
+ # Rich records carry sub-paragraphs, sometimes including a
27
+ # "<n>.x Specialty Shredouts:" table, which is parsed by the reused
28
+ # ShredoutParser. Records are returned in document order; #entries also
29
+ # carries each item's :number so callers can run the 1..N completeness check.
30
+ class RiListParser
31
+ DECORATIVE = Patterns::DECORATIVE
32
+
33
+ # A sub-paragraph line ("24.1.", "56.2.1.") — where a record's body begins.
34
+ # Distinct from the anchor line, whose "<n>." is immediately followed by a
35
+ # code (letter in the second position), not another number-and-dot.
36
+ SUBPARAGRAPH = /\A\s*\d+\.\d/
37
+
38
+ # The title's trailing change-date parenthetical.
39
+ DATE_PAREN = /\((?:Changed|Established|Effective|Change)\b/
40
+
41
+ # A sentence-ending period: a "." followed by whitespace or end of string.
42
+ SENTENCE_PERIOD = /\.(?=\s|\z)/
43
+
44
+ def initialize(text, config: Config.dafecd)
45
+ @config = config
46
+ @lines = text.lines.reject { |line| line =~ config.header }
47
+ end
48
+
49
+ # @return [Array<Hash>] one entry per RI code, in document order
50
+ def entries
51
+ records.filter_map { |record| parse(record) }
52
+ end
53
+
54
+ private
55
+
56
+ # Split the section into records at each top-level anchor line.
57
+ def records
58
+ records = []
59
+ current = nil
60
+ @lines.each do |line|
61
+ if line.match?(@config.ri_anchor)
62
+ current = +""
63
+ records << current
64
+ end
65
+ current << line if current
66
+ end
67
+ records
68
+ end
69
+
70
+ def parse(record)
71
+ lines = record.lines
72
+ m = lines.first.match(@config.ri_anchor)
73
+ return nil unless m
74
+
75
+ number = m[1].to_i
76
+ code = m[2]
77
+ region = title_region(m[3], lines.drop(1))
78
+
79
+ raw_title = extract_title(region)
80
+ name = raw_title && Title.titleize(raw_title)
81
+
82
+ {
83
+ number: number,
84
+ code: code,
85
+ name: name,
86
+ raw_title: raw_title,
87
+ changed_date: ChangeDate.extract(region),
88
+ glued_title: Title.glued?(name),
89
+ shredouts: shredouts(record)
90
+ }
91
+ end
92
+
93
+ # The joined header region: the anchor line's title remainder plus every
94
+ # continuation line up to the first sub-paragraph, sanitized and with page
95
+ # numbers dropped. This holds the title and its trailing date.
96
+ def title_region(anchor_rest, rest_lines)
97
+ parts = [Title.sanitize(anchor_rest)]
98
+ rest_lines.each do |line|
99
+ break if line.match?(SUBPARAGRAPH)
100
+ stripped = Title.sanitize(line)
101
+ next if stripped.empty?
102
+ next if stripped.match?(/\A\d+\z/) # bare page number
103
+ parts << stripped
104
+ end
105
+ parts.reject(&:empty?).join(" ")
106
+ end
107
+
108
+ # The title: the region up to the first date parenthetical or sentence
109
+ # period, whichever comes first; the whole region when neither is present
110
+ # (e.g. "DAFWorld Class Athlete Program (WCAP)").
111
+ def extract_title(region)
112
+ cut = [region =~ DATE_PAREN, region =~ SENTENCE_PERIOD].compact.min
113
+ title = (cut ? region[0...cut] : region).sub(/[\s.,]+\z/, "").strip
114
+ title.empty? ? nil : title
115
+ end
116
+
117
+ def shredouts(record)
118
+ ShredoutParser.new(
119
+ record, publication: @config, pair: ShredoutParser::RI_SDI_PAIR
120
+ ).parse
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patterns"
4
+ require_relative "../shredout_parser"
5
+ require_relative "config"
6
+ require_relative "change_date"
7
+ require_relative "title"
8
+
9
+ module GovCodes
10
+ module Dafecd
11
+ module RiSdi
12
+ # Parses ONE "SDI card" record block into per-code entries.
13
+ #
14
+ # A card block is a run of one or more "SDI <code>" anchor lines followed by
15
+ # a shared body: a title (on its own line, or inline after the code), a
16
+ # change-date annotation, numbered sections, and (occasionally) a
17
+ # "Specialty Shredouts" table. Enlisted codes are 5 chars ("8A200"); officer
18
+ # codes are 4 ("80C0"); the shape is supplied by the injected Config.
19
+ #
20
+ # A multi-code block (e.g. the Air Advisor codes 8L100/8L200/8L300, each
21
+ # with its own inline title but one shared body) yields one entry per code.
22
+ #
23
+ # A wrapped-prose false positive — an "SDI <code>," anchor whose inline
24
+ # "title" is actually the lowercase continuation of a sentence, e.g.
25
+ # "SDI 8P000, completion of a current T5 Investigation ..." — is rejected:
26
+ # its title fails the real-title test, leaving the block with no valid
27
+ # anchor, so #entries returns [].
28
+ class SdiCardParser
29
+ DECORATIVE = Patterns::DECORATIVE
30
+
31
+ Anchor = Struct.new(:code, :inline_title, keyword_init: true)
32
+
33
+ # A bare (keyword-less) date parenthetical, e.g. "(31 Oct 24)" — the date
34
+ # annotation on the officer 88X0 card carries no Changed/Established word.
35
+ BARE_DATE = /\(\s*(\d{1,2}\s*[A-Za-z]{3,9}\s*\d{2,4})\s*\)/
36
+ BARE_DATE_LINE = /\A#{BARE_DATE}/
37
+
38
+ # A keyword date annotation ("(Changed ...)"), open paren optional.
39
+ KEYWORD_DATE_LINE = /\A\(?\s*(?:Changed|Established|Effective|Change)\b/
40
+
41
+ def initialize(record, config: Config.dafecd)
42
+ @config = config
43
+ @lines = record.lines.reject { |line| line =~ config.header }
44
+ end
45
+
46
+ # @return [Array<Hash>] one entry hash per code in the block
47
+ def entries
48
+ anchors = self.anchors
49
+ return [] if anchors.empty?
50
+
51
+ shared = shared_title
52
+ date = changed_date
53
+ shreds = shredouts
54
+
55
+ anchors.map do |anchor|
56
+ raw = anchor.inline_title || shared
57
+ name = raw && Title.titleize(raw)
58
+ {
59
+ code: anchor.code,
60
+ name: name,
61
+ raw_title: raw,
62
+ changed_date: date,
63
+ glued_title: Title.glued?(name),
64
+ shredouts: shreds
65
+ }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # The valid SDI anchors in document order. An inline-title anchor whose
72
+ # title is not a real title is treated as a non-anchor (the false-positive
73
+ # rejection); a standalone "SDI <code>" line is always a valid anchor.
74
+ def anchors
75
+ @lines.filter_map do |line|
76
+ if (m = line.match(@config.sdi_anchor))
77
+ Anchor.new(code: m[1], inline_title: nil)
78
+ elsif (m = line.match(@config.sdi_inline_anchor))
79
+ title = Title.sanitize(m[2])
80
+ Title.real?(title) ? Anchor.new(code: m[1], inline_title: title) : nil
81
+ end
82
+ end
83
+ end
84
+
85
+ # Index of the last VALID anchor line, or nil. A false-positive inline
86
+ # anchor in the body ("SDI 8P000, completion of ...") must not be treated
87
+ # as an anchor here, or the standalone card's title lookup would start
88
+ # after it and find nothing.
89
+ def last_anchor_index
90
+ @lines.each_index.reverse_each.find { |i| valid_anchor?(@lines[i]) }
91
+ end
92
+
93
+ def valid_anchor?(line)
94
+ return true if line.match?(@config.sdi_anchor)
95
+ m = line.match(@config.sdi_inline_anchor)
96
+ m && Title.real?(Title.sanitize(m[2]))
97
+ end
98
+
99
+ # The standalone title: title lines between the last anchor and the date /
100
+ # first numbered section, joined and sanitized. nil when the block carries
101
+ # only inline titles (or no title line).
102
+ def shared_title
103
+ last = last_anchor_index
104
+ return nil if last.nil?
105
+
106
+ collected = []
107
+ @lines[(last + 1)..].each do |line|
108
+ stripped = Title.sanitize(line)
109
+ next if stripped.empty?
110
+ next if stripped.match?(/\A\d+\z/) # bare page number
111
+ break if date_line?(stripped)
112
+ break if stripped.match?(/\A\d+\./) # numbered section
113
+ break unless Title.title_line?(stripped)
114
+ collected << stripped
115
+ end
116
+ collected.empty? ? nil : collected.join(" ")
117
+ end
118
+
119
+ # Everything up to the first numbered section, where the change date lives.
120
+ def head_region
121
+ cut = @lines.index { |line| line.match?(/\A\s*\d+\.\s/) }
122
+ (cut ? @lines[0...cut] : @lines).join
123
+ end
124
+
125
+ # The change date: a keyword annotation, or (officer 88X0) a keyword-less
126
+ # bare date parenthetical in the card's header region.
127
+ def changed_date
128
+ region = head_region
129
+ ChangeDate.extract(region) || bare_date(region)
130
+ end
131
+
132
+ def bare_date(region)
133
+ m = region.match(BARE_DATE)
134
+ m && ChangeDate.normalize(m[1])
135
+ end
136
+
137
+ def shredouts
138
+ ShredoutParser.new(
139
+ @lines.join, publication: @config, pair: ShredoutParser::RI_SDI_PAIR
140
+ ).parse
141
+ end
142
+
143
+ # A line that ends title collection: a keyword date annotation OR a bare
144
+ # date parenthetical ("(31 Oct 24)").
145
+ def date_line?(stripped)
146
+ stripped.match?(KEYWORD_DATE_LINE) || stripped.match?(BARE_DATE_LINE)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "title"
5
+
6
+ module GovCodes
7
+ module Dafecd
8
+ module RiSdi
9
+ # Splits a Special Duty Identifiers (SDI) section into one string per record.
10
+ #
11
+ # A record begins at either:
12
+ # * a run of "SDI <code>" card anchors (the first anchor of the run), or
13
+ # * a "CEM Code <code>" line — the single ladder record (Premier Honor
14
+ # Guard, 8G000) embedded in the enlisted AF SDI section. It is split out
15
+ # as its own record so its body (notably its shredout table) can never
16
+ # bleed into the preceding card; the AFSC pipeline parses it downstream.
17
+ #
18
+ # Running page headers are stripped first. Consecutive card anchors stay in
19
+ # one record (the multi-code Air Advisor blocks). A wrapped-prose false
20
+ # positive ("SDI 8P000, completion of a current T5 ...") does NOT start a
21
+ # record — its inline "title" fails the real-title test, so it is left as
22
+ # body of the surrounding record and later rejected by SdiCardParser.
23
+ class SdiSectionSplitter
24
+ def initialize(text, config: Config.dafecd)
25
+ @config = config
26
+ @lines = text.lines.reject { |line| line =~ config.header }
27
+ end
28
+
29
+ # @return [Array<String>] one string per record
30
+ def records
31
+ records = []
32
+ current = nil
33
+ prev_meaningful_was_anchor = false
34
+
35
+ @lines.each do |line|
36
+ is_card = card_anchor?(line)
37
+ is_cem = line.match?(@config.cem)
38
+ stripped = line.strip
39
+ neutral = stripped.empty? || stripped.match?(/\A\d+\z/)
40
+
41
+ starts_record = is_cem || (is_card && !prev_meaningful_was_anchor)
42
+
43
+ if starts_record
44
+ current = +""
45
+ records << current
46
+ end
47
+
48
+ current << line if current
49
+
50
+ unless neutral
51
+ # A CEM line's ladder ("AFSC 8G091 ...") keeps the run open so the
52
+ # card detector does not re-trigger inside the ladder record.
53
+ prev_meaningful_was_anchor = is_card || is_cem || ladder_line?(line)
54
+ end
55
+ end
56
+
57
+ records
58
+ end
59
+
60
+ private
61
+
62
+ # A card anchor: a standalone "SDI <code>" line, or an inline-title anchor
63
+ # whose title is a real title (rejecting the wrapped-prose false positive).
64
+ def card_anchor?(line)
65
+ return true if line.match?(@config.sdi_anchor)
66
+ m = line.match(@config.sdi_inline_anchor)
67
+ m && Title.real?(Title.sanitize(m[2]))
68
+ end
69
+
70
+ # A ladder line inside the embedded CEM record, e.g. "AFSC 8G091 ...".
71
+ def ladder_line?(line)
72
+ line.match?(/^\s*AFSC\s+\d[A-Z]\d\d\d/)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module GovCodes
6
+ module Dafecd
7
+ module RiSdi
8
+ # Slices the full directory text into the RI/SDI sections named in the
9
+ # injected Config.
10
+ #
11
+ # Each section is located by its header line (e.g.
12
+ # "AIR FORCE REPORTING IDENTIFIERS (RI)"). The header regexes require the
13
+ # "(SDI)"/"(RI)"/"(SFSC)" parenthetical, which the table-of-contents entries
14
+ # lack, so the TOC is never mistaken for a section. A section runs from its
15
+ # header to the next located header. The out-of-scope Space Force Specialty
16
+ # Codes (SFSC) section is used only as a boundary (it terminates the AF RI
17
+ # section) and is dropped from the result.
18
+ class SectionSlicer
19
+ Section = Struct.new(:kind, :force, :text, keyword_init: true)
20
+
21
+ def initialize(text, config: Config.dafecd)
22
+ @text = text
23
+ @config = config
24
+ end
25
+
26
+ # @return [Array<Section>] located, in-scope sections in document order
27
+ def sections
28
+ located = @config.sections
29
+ .map { |spec| {spec: spec, at: (@text =~ spec[:header])} }
30
+ .reject { |m| m[:at].nil? }
31
+ .sort_by { |m| m[:at] }
32
+
33
+ located.each_with_index.map do |mark, i|
34
+ finish = (i + 1 < located.size) ? located[i + 1][:at] : @text.length
35
+ Section.new(
36
+ kind: mark[:spec][:kind],
37
+ force: mark[:spec][:force],
38
+ text: @text[mark[:at]...finish]
39
+ )
40
+ end.reject { |section| section.kind == :skip }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end