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,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GovCodes
4
+ module Dafecd
5
+ # Per-publication configuration for the classification-directory extractor.
6
+ #
7
+ # The DAFECD (enlisted) and DAFOCD (officer) directories share the same
8
+ # extraction pipeline (RecordSplitter -> SpecialtyParser -> ShredoutParser ->
9
+ # IndexBuilder) but differ in a handful of concrete details: the running
10
+ # header, the shape of a skill/qualification ladder line, whether standalone
11
+ # single-code records and CEM lines exist, how the X-form specialty key is
12
+ # derived, and where the release artifact is written.
13
+ #
14
+ # A Publication is an immutable value object capturing exactly those
15
+ # differences. It is injected into every pipeline stage. The default
16
+ # everywhere is +Publication.dafecd+, so the enlisted extraction is
17
+ # byte-for-byte unchanged by the parameterization.
18
+ class Publication
19
+ # --- Enlisted (DAFECD) patterns ------------------------------------------
20
+ # Running page header, e.g. "DAFECD, 31 Oct 25".
21
+ DAFECD_HEADER = /^\s*DAFECD,\s+\d/
22
+
23
+ # Skill-ladder line (group 1 = concrete 5-char AFSC, group 2 = skill-level
24
+ # word). Tolerates the DAFECD's pdf-reader quirks (see Patterns::LADDER).
25
+ DAFECD_LADDER = /
26
+ ^\s*(?:AFSC\s+)?(\d[A-Z]\d\d\d)\*?
27
+ (?:\s+or\s+\d[A-Z0-9]+)?
28
+ ,?\s*
29
+ (?:[A-Za-z][A-Za-z\/]*\s+){0,3}
30
+ (Helper|Apprentice|Journeyman|Craftsman|
31
+ Superintendent|Senior\s+Enlisted(?:\s+Leader)?|Entry)
32
+ (?:\s*\([A-Z]{2,6}\))?
33
+ (?:\s*AFSC)?
34
+ \s*$
35
+ /x
36
+
37
+ # CEM (Chief Enlisted Manager) code line, e.g. "CEM Code 1A100*".
38
+ DAFECD_CEM = /^\s*CEM Code\s+(\d[A-Z]\d00)\*?/
39
+
40
+ # Inserts a boundary before an "AFSC <enlisted-code>" glued to a word.
41
+ DAFECD_GLUED_AFSC = /(?<=[A-Za-z])(?=AFSC \d[A-Z]\d\d\d)/
42
+
43
+ # The DAFECD's per-record change-date annotation.
44
+ DAFECD_CHANGE_DATE = /\((?:Changed|Established|Effective)\s+(\d{1,2}\s+\w{3,9}\s+\d{2,4})\)/
45
+
46
+ # The DAFECD shredout table header ("Suffix ... Primary Aircraft").
47
+ DAFECD_SHREDOUT_HEADER = /Suffix\s+\w/
48
+
49
+ # --- Officer (DAFOCD) patterns -------------------------------------------
50
+ # Running page header, e.g. "DAFOCD, 31 Oct 25".
51
+ DAFOCD_HEADER = /^\s*DAFOCD,\s+\d/
52
+
53
+ # Qualification-ladder line (group 1 = concrete 4-char AFSC, group 2 =
54
+ # free-form qualification title). The officer title vocabulary is open, so
55
+ # the anchor relies on the CODE SHAPE (exactly 4 chars: two digits, a
56
+ # letter, and a level digit 1-4) plus a SHORT title that ENDS the line AND
57
+ # STARTS WITH AN UPPERCASE LETTER. Every real DAFOCD qualification title is
58
+ # a proper label (Entry, Qualified, Aircraft Commander, Staff, ...), so the
59
+ # uppercase-initial requirement rejects wrapped source PROSE that happens to
60
+ # begin "AFSC <4-char-code>, <lowercase words>" (e.g. the real lines
61
+ # "AFSC 14F3, but applied to developing" and "AFSC 42G3, current and
62
+ # continuous"), which otherwise satisfied the code+short-title shape. It
63
+ # also still rejects the 5-char shred-code / long-sentence prose mentions.
64
+ # An optional leading/trailing "AFSC" absorbs the same pdf-reader glue the
65
+ # enlisted ladder handles (e.g. "11G4, StaffAFSC").
66
+ #
67
+ # The directory is INCONSISTENT about three things, all tolerated here:
68
+ # * COMMA (group A): most cards print "AFSC 11B4, Staff", but several live
69
+ # medical cards omit the comma and separate with a space ("AFSC 44B4
70
+ # Staff", "AFSC 45A4* Staff"). Hence the separator is comma-or-space,
71
+ # NOT a bare optional comma: requiring at least one of comma/whitespace
72
+ # is what still rejects glued-shred prose such as "AFSC 42B3Z requires
73
+ # successful completion of" and "AFSC 43E3Aand complete the education",
74
+ # where an uppercase shred letter is glued to the code with no
75
+ # separator before the lowercase prose. A bare ",?" would let those in.
76
+ # * SHRED LETTER (group B): a few cards print the qualification code with
77
+ # a glued shredout letter, e.g. "AFSC 17D4W*, Staff". The optional
78
+ # "[A-Z]" absorbs it so it is NOT baked into the captured concrete code
79
+ # (group 1 stays "17D4"); the letter is documented as a shredout via the
80
+ # card's shredout table instead (see ShredoutParser).
81
+ # * LEADING GLYPH: pdf-reader prepends a Private Use Area / bullet glyph
82
+ # to some ladder lines (e.g. U+F0EA on the 17D card). The leading class
83
+ # mirrors Patterns::DECORATIVE so the anchor tolerates it.
84
+ DAFOCD_LADDER = %r{
85
+ ^[\s\u{E000}-\u{F8FF}★☆•●▪■⁃∙]*(?:AFSC\s+)?(\d\d[A-Z][1-4])[A-Z]?\*?
86
+ (?:,\s*|\s+)
87
+ ([A-Z][A-Za-z/\ ]{0,44}?)
88
+ (?:\s*AFSC)?
89
+ \s*$
90
+ }x
91
+
92
+ # A standalone single-code officer record, e.g. "AFSC 10C0". These carry a
93
+ # title/date/sections but no ladder; each is a full record keyed by the
94
+ # literal code.
95
+ DAFOCD_BARE_CODE = /^\s*AFSC\s+(\d\d[A-Z][0-9X])\s*$/
96
+
97
+ # Inserts a boundary before an "AFSC <officer-code>" glued to a word.
98
+ DAFOCD_GLUED_AFSC = /(?<=[A-Za-z])(?=AFSC \d\d[A-Z][1-4])/
99
+
100
+ # The DAFOCD's per-record change-date annotation. Tolerant of the officer
101
+ # directory's glued day/month ("30Apr 23") and of a trailing second date
102
+ # ("30Apr 14, Effective 25 Oct 13"): the first annotation is captured.
103
+ DAFOCD_CHANGE_DATE = /\((?:Changed|Established|Effective)\s+(\d{1,2}\s*\w{3,9}\s+\d{2,4})/
104
+
105
+ # The DAFOCD shredout table header ("Suffix ... Portion of AFS to Which
106
+ # Related").
107
+ DAFOCD_SHREDOUT_HEADER = /Suffix\s+\w/
108
+
109
+ class << self
110
+ # @return [Publication] the enlisted directory configuration
111
+ def dafecd
112
+ @dafecd ||= new(
113
+ id: :dafecd,
114
+ directory_name: "Department of the Air Force Enlisted Classification Directory",
115
+ header: DAFECD_HEADER,
116
+ ladder: DAFECD_LADDER,
117
+ bare_code: nil,
118
+ cem: DAFECD_CEM,
119
+ glued_afsc: DAFECD_GLUED_AFSC,
120
+ change_date: DAFECD_CHANGE_DATE,
121
+ shredout_header: DAFECD_SHREDOUT_HEADER,
122
+ levels_key: :skill_levels,
123
+ code_has_specific: true,
124
+ captures_shredout_acronyms: false,
125
+ acronym_exclusions: %i[1D7X2 1A8X0],
126
+ release_dir: "dafecd",
127
+ index_filename: "enlisted.yml",
128
+ title_overrides_filename: "title_overrides.yml",
129
+ shredout_overrides_filename: "shredout_overrides/dafecd.yml"
130
+ )
131
+ end
132
+
133
+ # @return [Publication] the officer directory configuration
134
+ def dafocd
135
+ @dafocd ||= new(
136
+ id: :dafocd,
137
+ directory_name: "Department of the Air Force Officer Classification Directory",
138
+ header: DAFOCD_HEADER,
139
+ ladder: DAFOCD_LADDER,
140
+ bare_code: DAFOCD_BARE_CODE,
141
+ cem: nil,
142
+ glued_afsc: DAFOCD_GLUED_AFSC,
143
+ change_date: DAFOCD_CHANGE_DATE,
144
+ shredout_header: DAFOCD_SHREDOUT_HEADER,
145
+ levels_key: :qual_levels,
146
+ code_has_specific: false,
147
+ captures_shredout_acronyms: true,
148
+ acronym_exclusions: [],
149
+ release_dir: "dafocd",
150
+ index_filename: "officer.yml",
151
+ title_overrides_filename: "title_overrides/dafocd.yml",
152
+ shredout_overrides_filename: "shredout_overrides/dafocd.yml"
153
+ )
154
+ end
155
+
156
+ # Select the publication that matches +text+'s running header, defaulting
157
+ # to enlisted.
158
+ def detect(text)
159
+ [dafocd, dafecd].find { |pub| pub.header.match?(text) } || dafecd
160
+ end
161
+
162
+ # Look a publication up by its id symbol (:dafecd / :dafocd).
163
+ def for(id)
164
+ by_id = {dafecd: dafecd, dafocd: dafocd}
165
+ by_id.fetch(id.to_sym) do
166
+ raise ArgumentError,
167
+ "unknown publication #{id.inspect}; valid options are #{by_id.keys.map(&:inspect).join(", ")}"
168
+ end
169
+ end
170
+ end
171
+
172
+ attr_reader :id, :directory_name, :header, :ladder, :bare_code, :cem,
173
+ :glued_afsc, :change_date, :shredout_header, :levels_key,
174
+ :acronym_exclusions, :release_dir, :index_filename,
175
+ :title_overrides_filename, :shredout_overrides_filename
176
+
177
+ def initialize(id:, directory_name:, header:, ladder:, bare_code:, cem:,
178
+ glued_afsc:, change_date:, shredout_header:, levels_key:,
179
+ code_has_specific:, captures_shredout_acronyms:, acronym_exclusions:,
180
+ release_dir:, index_filename:, title_overrides_filename:,
181
+ shredout_overrides_filename:)
182
+ @id = id
183
+ @directory_name = directory_name
184
+ @header = header
185
+ @ladder = ladder
186
+ @bare_code = bare_code
187
+ @cem = cem
188
+ @glued_afsc = glued_afsc
189
+ @change_date = change_date
190
+ @shredout_header = shredout_header
191
+ @levels_key = levels_key
192
+ @code_has_specific = code_has_specific
193
+ @captures_shredout_acronyms = captures_shredout_acronyms
194
+ @acronym_exclusions = acronym_exclusions
195
+ @release_dir = release_dir
196
+ @index_filename = index_filename
197
+ @title_overrides_filename = title_overrides_filename
198
+ @shredout_overrides_filename = shredout_overrides_filename
199
+ freeze
200
+ end
201
+
202
+ # Whether this publication extracts shredout-level acronyms (officer).
203
+ def captures_shredout_acronyms?
204
+ @captures_shredout_acronyms
205
+ end
206
+
207
+ # The X-form specialty key for a ladder group's concrete codes.
208
+ # enlisted: 1A172,1A152,... -> :1A1X2 (X at the level digit, specific kept)
209
+ # officer: 11B4,11B3,... -> :11BX (X at the level digit, no specific)
210
+ # Returns nil when no codes are given.
211
+ def specialty_key(codes)
212
+ return nil if codes.empty?
213
+ basis = specialty_basis(codes)
214
+ prefix = basis.first[0, 3]
215
+ @code_has_specific ? :"#{prefix}X#{most_common_specific(basis)}" : :"#{prefix}X"
216
+ end
217
+
218
+ # The career-field key (first two chars of the basis code).
219
+ def career_field(codes)
220
+ return nil if codes.empty?
221
+ :"#{specialty_basis(codes).first[0, 2]}"
222
+ end
223
+
224
+ # The absolute path to this publication's title-overrides file.
225
+ def title_overrides_path
226
+ File.expand_path(@title_overrides_filename, __dir__)
227
+ end
228
+
229
+ # The absolute path to this publication's shredout-overrides file.
230
+ def shredout_overrides_path
231
+ File.expand_path(@shredout_overrides_filename, __dir__)
232
+ end
233
+
234
+ private
235
+
236
+ # The ladder codes that define the specialty. Enlisted: a subdivision
237
+ # superintendent (skill digit 9) carries a specific digit of 0, so the
238
+ # specialty is defined by its non-superintendent levels. Officer: all ladder
239
+ # codes share the same three-char family, so every code is basis.
240
+ def specialty_basis(codes)
241
+ return codes unless @code_has_specific
242
+ non_super = codes.reject { |code| code[3] == "9" }
243
+ non_super.empty? ? codes : non_super
244
+ end
245
+
246
+ # The most frequent specific (5th) digit among the basis codes (enlisted).
247
+ def most_common_specific(basis)
248
+ basis.map { |code| code[4] }.group_by(&:itself).max_by { |_, v| v.size }.first
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+ require_relative "publication"
5
+ require_relative "text"
6
+
7
+ module GovCodes
8
+ # Dev-only tooling that parses an official Department of the Air Force
9
+ # classification directory (DAFECD enlisted / DAFOCD officer) PDF text into
10
+ # structured data.
11
+ #
12
+ # These classes are NEVER required by the gem runtime. They are used offline
13
+ # (via bin/extract_afsc_from_pdf.rb) to regenerate the versioned index when a
14
+ # new directory is released.
15
+ module Dafecd
16
+ # Splits the full directory text into one string per specialty record.
17
+ #
18
+ # A specialty record begins at either:
19
+ # * a "CEM Code <code>" line (enlisted only), or
20
+ # * a bare standalone "AFSC <code>" line (officer single-code records), or
21
+ # * the first ladder line ("AFSC <code>, <title>") of a ladder group that
22
+ # is NOT immediately preceded (ignoring blank lines) by another ladder
23
+ # line, CEM line, or bare-code line.
24
+ #
25
+ # Running page headers ("DAFECD, <date>" / "DAFOCD, <date>") are stripped
26
+ # before splitting. Behavior is publication-specific (injected Publication);
27
+ # the default is the enlisted directory.
28
+ class RecordSplitter
29
+ # A lone title-case word on its own line (e.g. "Leader" left behind when
30
+ # Text.split_glued_afsc splits "LeaderAFSC 1A178"). Such wrapped
31
+ # continuations must not break a ladder group.
32
+ CONTINUATION_WORD = /\A[A-Z][a-z]+\z/
33
+
34
+ def initialize(text, publication: Publication.dafecd)
35
+ @publication = publication
36
+ @text = Text.split_glued_afsc(text, publication.glued_afsc)
37
+ end
38
+
39
+ # @return [Array<String>] one string per specialty record
40
+ def records
41
+ lines = @text.lines.reject { |line| line =~ @publication.header }
42
+
43
+ records = []
44
+ current = nil
45
+ prev_meaningful_was_anchor = false
46
+
47
+ lines.each do |line|
48
+ is_ladder = line =~ @publication.ladder
49
+ is_cem = @publication.cem && line =~ @publication.cem
50
+ is_bare = @publication.bare_code && line =~ @publication.bare_code
51
+ stripped = line.strip
52
+ # Blank lines and lone continuation words are neutral: they neither
53
+ # start a record nor break a run of ladder/CEM/bare lines.
54
+ neutral = stripped.empty? || stripped.match?(CONTINUATION_WORD)
55
+
56
+ starts_record =
57
+ is_cem ||
58
+ is_bare ||
59
+ (is_ladder && !prev_meaningful_was_anchor)
60
+
61
+ if starts_record
62
+ current = +""
63
+ records << current
64
+ end
65
+
66
+ current << line if current
67
+
68
+ unless neutral
69
+ prev_meaningful_was_anchor = !!(is_ladder || is_cem || is_bare)
70
+ end
71
+ end
72
+
73
+ records
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../text"
4
+
5
+ module GovCodes
6
+ module Dafecd
7
+ module RiSdi
8
+ # Extracts the per-record change/effective date from an RI or SDI record's
9
+ # header region.
10
+ #
11
+ # The RI/SDI sections of the DAFECD/DAFOCD use noticeably messier date
12
+ # annotations than the AFSC ladder records the existing
13
+ # Publication#change_date patterns handle. Rather than loosen those shared
14
+ # patterns (which would risk perturbing the already-shipped enlisted/officer
15
+ # artifacts), this module owns a deliberately tolerant capture regex used
16
+ # only on the new content:
17
+ #
18
+ # * a leading extra word before the date
19
+ # "(Change Effective 31 Oct 18)", "(Established Effective 31 Oct 22)"
20
+ # * a long leading clause ending in a lowercase "effective"
21
+ # "(Change to description only effective 11 May 15)"
22
+ # * a glued day/month "(Established 30Apr 25)"
23
+ # * a dual date; the FIRST is captured (matching the officer convention)
24
+ # "(Changed 31 Oct 16, Effective 8 Feb 16)" -> 2016-10-31
25
+ # * a missing/misplaced open paren (pdf glue)
26
+ # "Established 31 Oct 25 )"
27
+ #
28
+ # The captured substring is normalized to ISO 8601 by the shared
29
+ # Text.parse_date (which already tolerates the glued day/month). Returns nil
30
+ # when the given text carries no such annotation.
31
+ module ChangeDate
32
+ # A directory date: "31 Oct 25", "5 June 2013", the glued "30Apr 25", or
33
+ # the fully glued "30Apr20" (officer 81D0). The gap before BOTH the month
34
+ # and the year is optional to absorb pdf-reader's glue.
35
+ DATE = /\d{1,2}\s*[A-Za-z]{3,9}\s*\d{2,4}/
36
+
37
+ # A change-date annotation: a capitalized change keyword, then a lazy run
38
+ # of same-annotation filler (no newline, no close paren), then the first
39
+ # date. The lazy filler is what captures the FIRST date of a dual-date
40
+ # line and absorbs the "Effective"/"effective"/"to description only"
41
+ # bridging words.
42
+ ANNOTATION = /(?:Changed|Established|Effective|Change)\b[^)\n]*?(#{DATE})/
43
+
44
+ module_function
45
+
46
+ # @param text [String] the record's header region (or any text slice)
47
+ # @return [String, nil] the ISO 8601 change date, or nil when absent
48
+ def extract(text)
49
+ raw = text.to_s[ANNOTATION, 1]
50
+ raw && normalize(raw)
51
+ end
52
+
53
+ # Normalize an already-isolated raw date ("31 Oct 24", "30Apr20") to ISO
54
+ # 8601. Re-spaces any letter/digit glue so the shared date normalizer,
55
+ # which needs a gap before the year, can read it. Returns nil when the raw
56
+ # is not a date.
57
+ def normalize(raw)
58
+ Text.parse_date(raw.to_s.gsub(/([A-Za-z])(?=\d)/, '\1 ').gsub(/(\d)(?=[A-Za-z])/, '\1 '))
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patterns"
4
+
5
+ module GovCodes
6
+ module Dafecd
7
+ module RiSdi
8
+ # Per-publication configuration for the RI/SDI extractor.
9
+ #
10
+ # The DAFECD (enlisted) and DAFOCD (officer) directories publish their
11
+ # Reporting Identifiers (RI) and Special Duty Identifiers (SDI) in the same
12
+ # two record formats (the SDI "card" and the flat numbered RI list). They
13
+ # differ only in the code SHAPE (enlisted codes are 5 chars, "8A200";
14
+ # officer codes are 4 chars, "80C0"), the running page header, and where the
15
+ # combined artifact is written. A Config captures exactly those differences
16
+ # and is injected into every stage.
17
+ class Config
18
+ # Enlisted RI/SDI code: a digit, a letter, three digits (8A200, 9Z200,
19
+ # 5I000, 5Z700). Space Force RI reuses the same shape.
20
+ ENLISTED_CODE = /\d[A-Z]\d{3}/
21
+
22
+ # Officer RI/SDI code: two digits, a letter, a level digit (80C0, 90G0).
23
+ # The trailing character is a DIGIT — never "X" — which is what rejects
24
+ # the academic CIP codes ("05XX", "14.10XX") that share the officer RI
25
+ # list's page real estate.
26
+ OFFICER_CODE = /\d\d[A-Z]\d/
27
+
28
+ DAFECD_HEADER = /^\s*DAFECD,\s+\d/
29
+ DAFOCD_HEADER = /^\s*DAFOCD,\s+\d/
30
+
31
+ # Both directories head the shredout table with "Suffix ... Portion of
32
+ # <AFS|RI> to Which Related"; the leading "Suffix <word>" is common to
33
+ # every variant, so the existing header regex matches them all.
34
+ SHREDOUT_HEADER = /Suffix\s+\w/
35
+
36
+ class << self
37
+ def dafecd
38
+ @dafecd ||= new(
39
+ id: :dafecd,
40
+ directory_name: "Department of the Air Force Enlisted Classification Directory",
41
+ code: ENLISTED_CODE,
42
+ header: DAFECD_HEADER,
43
+ release_dir: "dafecd",
44
+ # Trailing parentheticals that abbreviate an embedded organization
45
+ # or a sub-phrase — NOT the identifier's own name — and so must not
46
+ # ship as the record's acronym. See the acronym classification in
47
+ # the extractor report. (SWMS/AFSPECWAR/MEPCOM/etc. are mid-title or
48
+ # length-excluded and never captured, so they need no listing here.)
49
+ acronym_exclusions: %i[9B100 9H100 9M400],
50
+ # AF SDI -> AF RI -> (skip SFSC) -> SF SDI -> SF RI
51
+ sections: [
52
+ {kind: :sdi, force: :af, header: /^\s*SPECIALDUTY IDENTIFIERS \(SDI\)/},
53
+ {kind: :ri, force: :af, header: /^\s*AIR FORCE REPORTING IDENTIFIERS \(RI\)/},
54
+ {kind: :skip, force: :sf, header: /^\s*SPACE FORCE SPECIALTY CODES \(SFSC\)/},
55
+ {kind: :sdi, force: :sf, header: /^\s*SPACE FORCE SPECIAL ?DUTY IDENTIFIERS \(SDI\)/},
56
+ {kind: :ri, force: :sf, header: /^\s*SPACE FORCE REPORTING IDENTIFIERS \(RI\)/}
57
+ ]
58
+ )
59
+ end
60
+
61
+ def dafocd
62
+ @dafocd ||= new(
63
+ id: :dafocd,
64
+ directory_name: "Department of the Air Force Officer Classification Directory",
65
+ code: OFFICER_CODE,
66
+ header: DAFOCD_HEADER,
67
+ release_dir: "dafocd",
68
+ acronym_exclusions: [],
69
+ # Officer SDI -> Officer RI (no AF/SF split in the officer directory)
70
+ sections: [
71
+ {kind: :sdi, force: :officer, header: /^\s*SPECIAL ?DUTY IDENTIFIERS \(SDI\)/},
72
+ {kind: :ri, force: :officer, header: /^\s*REPORTING IDENTIFIERS \(RI\)/}
73
+ ]
74
+ )
75
+ end
76
+
77
+ def for(id)
78
+ by_id = {dafecd: dafecd, dafocd: dafocd}
79
+ by_id.fetch(id.to_sym) do
80
+ raise ArgumentError,
81
+ "unknown publication #{id.inspect}; valid options are #{by_id.keys.map(&:inspect).join(", ")}"
82
+ end
83
+ end
84
+ end
85
+
86
+ attr_reader :id, :directory_name, :code, :header, :shredout_header,
87
+ :release_dir, :sections, :index_filename, :acronym_exclusions,
88
+ :title_overrides_filename, :sdi_anchor, :sdi_inline_anchor, :ri_anchor, :cem
89
+
90
+ def initialize(id:, directory_name:, code:, header:, release_dir:, sections:, acronym_exclusions:)
91
+ @id = id
92
+ @directory_name = directory_name
93
+ @code = code
94
+ @header = header
95
+ @shredout_header = SHREDOUT_HEADER
96
+ @release_dir = release_dir
97
+ @sections = sections
98
+ @acronym_exclusions = acronym_exclusions
99
+ @index_filename = "ri.yml"
100
+ # Human-verified de-glued titles, one file per publication, gated at
101
+ # build time against the verbatim source (spacing/case only).
102
+ @title_overrides_filename = "title_overrides/#{id}.yml"
103
+ # A standalone SDI card anchor ("SDI 8A200") and an inline-title anchor
104
+ # ("SDI 8L100,AirAdvisor - Basic"); group 1 is the code, group 2 (inline
105
+ # only) the raw title text. The inline anchor's "SDI " keyword is
106
+ # OPTIONAL: the officer Air Advisor cards (89A0-89I0) print as a bare
107
+ # "CODE,Title" line with no prefix. False positives are still rejected
108
+ # downstream by Title.real? (the inline "title" must start with a
109
+ # capital/digit/paren, never a wrapped-prose lowercase continuation),
110
+ # and the "^" anchor keeps the code at line start so mid-line codes
111
+ # (e.g. "...10-4301V1, Air Advisor Training") never match.
112
+ @sdi_anchor = /^\s*SDI\s+(#{code})\*?\s*$/
113
+ @sdi_inline_anchor = /^\s*(?:SDI\s+)?(#{code})\*?,\s*(\S.*)$/
114
+ # A flat-list RI anchor: a top-level list number, an optional decorative
115
+ # star, then the code. Group 1 is the list number, group 2 the code,
116
+ # group 3 the remainder of the line (title, possibly with a trailing
117
+ # date). Tolerates a run of spaces or none after "<n>.", and a comma or
118
+ # a bare space before the title.
119
+ @ri_anchor =
120
+ /^[ \t]*(\d{1,2})\.[ \t]*(?:#{Patterns::DECORATIVE}[ \t]*)*(#{code})\s*,?\s*(.*)$/
121
+ # The one ladder record embedded in the enlisted AF SDI section
122
+ # ("CEM Code 8G000", Premier Honor Guard) is handled by the AFSC
123
+ # pipeline; the splitter uses this to isolate it from the cards.
124
+ @cem = Patterns::CEM
125
+ freeze
126
+ end
127
+
128
+ # The absolute path to this publication's RI/SDI title-overrides file.
129
+ # Mirrors Publication#title_overrides_path so the shared TitleDegluer
130
+ # (its .for/#override_for/.matches_source? API) loads from a Config too.
131
+ def title_overrides_path
132
+ File.expand_path(@title_overrides_filename, __dir__)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end