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
@@ -1,64 +1,100 @@
1
1
  require "strscan"
2
- require "yaml"
3
- require_relative "../data_loader"
2
+ require_relative "releases"
4
3
 
5
4
  module GovCodes
6
5
  module AFSC
7
- # Reporting Identifiers (RI) and Special Duty Identifiers (SDI)
8
- # These codes follow a different format than standard AFSCs:
9
- # - Career field: digit + letter (e.g., "9Z", "8A")
10
- # - Identifier: 3 digits (e.g., "200", "400")
11
- # - Optional suffix: letter (e.g., "A", "B")
6
+ # Reporting Identifiers (RI) and Special Duty Identifiers (SDI), resolved
7
+ # against the versioned classification-directory release indexes. RI/SDI
8
+ # codes live in BOTH directories under two different code grammars:
12
9
  #
13
- # Examples: 9Z200, 8A400, 8G000B, 8R300A
10
+ # Enlisted (DAFECD), 5-char: \d[A-Z]\d{3}[A-Z]? e.g. 9Z200, 8R300A
11
+ # Officer (DAFOCD), 4-char: \d{2}[A-Z]\d[A-Z]? e.g. 90G0, 92T1
12
+ #
13
+ # A single +find+ entry point dispatches by shape to the matching
14
+ # publication's ri.yml index (Releases.ri_index). Entries share the shape of
15
+ # enlisted/officer specialty records (:name, :acronym, :shredouts,
16
+ # :shredout_acronyms), so shredout suffixes and acronyms resolve exactly as
17
+ # they do for Enlisted/Officer.
14
18
  module RI
15
19
  class Parser
16
20
  def initialize(code)
17
- @code = code
21
+ @code = code.to_s
18
22
  end
19
23
 
24
+ # Try the 5-char enlisted shape first, then the 4-char officer shape.
25
+ # Both shapes are mutually exclusive (an enlisted code's 2nd char is a
26
+ # letter; an officer code's 2nd char is a digit), so at most one matches.
27
+ # The result carries the decomposed fields plus the :publication whose
28
+ # index should resolve the name; all nil when neither shape parses.
20
29
  def parse
21
- scanner = StringScanner.new(@code.to_s)
22
- result = {
30
+ parse_enlisted || parse_officer || empty_result
31
+ end
32
+
33
+ private
34
+
35
+ def empty_result
36
+ {
23
37
  career_group: nil,
24
38
  career_field: nil,
25
39
  identifier: nil,
26
40
  suffix: nil,
27
- specific_ri: nil
41
+ specific_ri: nil,
42
+ publication: nil
28
43
  }
44
+ end
29
45
 
30
- # Scan for career group (single digit)
31
- career_group = scanner.scan(/\d/)
32
- return result unless career_group
33
- result[:career_group] = career_group.to_sym
34
-
35
- # Scan for career field letter
36
- career_field_letter = scanner.scan(/[A-Z]/)
37
- return result unless career_field_letter
38
- result[:career_field] = :"#{career_group}#{career_field_letter}"
39
-
40
- # Scan for identifier (3 digits)
41
- identifier = scanner.scan(/\d{3}/)
42
- return result unless identifier
43
- result[:identifier] = identifier.to_sym
44
-
45
- # Build specific RI code
46
- result[:specific_ri] = :"#{result[:career_field]}#{identifier}"
47
-
48
- # Scan for optional suffix (letter)
46
+ # Enlisted shape: career_group (1 digit), career_field (digit+letter,
47
+ # e.g. "9Z"), identifier (3 digits), optional 1-letter suffix.
48
+ def parse_enlisted
49
+ scanner = StringScanner.new(@code)
50
+ career_group = scanner.scan(/\d/) or return nil
51
+ field_letter = scanner.scan(/[A-Z]/) or return nil
52
+ identifier = scanner.scan(/\d{3}/) or return nil
49
53
  suffix = scanner.scan(/[A-Z]/)
50
- result[:suffix] = suffix&.to_sym
51
-
52
- # Check if we've reached the end of the string
53
- return result unless scanner.eos?
54
+ return nil unless scanner.eos?
55
+
56
+ career_field = :"#{career_group}#{field_letter}"
57
+ {
58
+ career_group: career_group.to_sym,
59
+ career_field: career_field,
60
+ identifier: identifier.to_sym,
61
+ suffix: suffix&.to_sym,
62
+ specific_ri: :"#{career_field}#{identifier}",
63
+ publication: Releases::ENLISTED_PUBLICATION
64
+ }
65
+ end
54
66
 
55
- result
67
+ # Officer shape: same grammar as an officer AFSC (2-digit career group +
68
+ # functional-area letter + 1-digit qualification level). There is no
69
+ # natural 3-digit identifier here, so these codes are mapped onto the
70
+ # SAME Code struct with the closest-matching semantics:
71
+ # career_group the 2-digit prefix (e.g. :"90")
72
+ # career_field 2-digit + letter (e.g. :"90G")
73
+ # identifier the trailing digit (e.g. :"0") <- 1 digit, not 3;
74
+ # intentional, not a bug
75
+ # specific_ri the full 4-char code (e.g. :"90G0")
76
+ def parse_officer
77
+ scanner = StringScanner.new(@code)
78
+ career_group = scanner.scan(/\d{2}/) or return nil
79
+ field_letter = scanner.scan(/[A-Z]/) or return nil
80
+ identifier = scanner.scan(/\d/) or return nil
81
+ suffix = scanner.scan(/[A-Z]/)
82
+ return nil unless scanner.eos?
83
+
84
+ career_field = :"#{career_group}#{field_letter}"
85
+ {
86
+ career_group: career_group.to_sym,
87
+ career_field: career_field,
88
+ identifier: identifier.to_sym,
89
+ suffix: suffix&.to_sym,
90
+ specific_ri: :"#{career_field}#{identifier}",
91
+ publication: Releases::OFFICER_PUBLICATION
92
+ }
56
93
  end
57
94
  end
58
95
 
59
- extend GovCodes::DataLoader
60
-
61
- DATA = data
96
+ CODES = {}
97
+ private_constant :CODES
62
98
 
63
99
  Code = Data.define(
64
100
  :career_group,
@@ -66,107 +102,106 @@ module GovCodes
66
102
  :identifier,
67
103
  :suffix,
68
104
  :specific_ri,
69
- :name
105
+ :name,
106
+ :acronym,
107
+ :effective_date
70
108
  )
71
109
 
72
- def self.find_name_recursive(result)
73
- name = nil
74
- data = DATA
75
-
76
- career_field = result[:career_field]
77
- identifier = result[:identifier]
78
- suffix = result[:suffix]
79
-
80
- # Look for the career field (e.g., "9Z", "8A")
81
- if data[career_field]
82
- field_data = data[career_field]
83
-
84
- # Career field has name and subcategories
85
- if field_data.is_a?(Hash) && field_data[:subcategories]
86
- subcats = field_data[:subcategories]
87
-
88
- # Look for the identifier (e.g., "200", "400")
89
- if subcats[identifier]
90
- identifier_data = subcats[identifier]
91
-
92
- if identifier_data.is_a?(Hash)
93
- name = identifier_data[:name]
94
-
95
- # Look for suffix if present
96
- if suffix && identifier_data[:subcategories]
97
- suffix_data = identifier_data[:subcategories][suffix]
98
- name = suffix_data if suffix_data.is_a?(String)
99
- name = suffix_data[:name] if suffix_data.is_a?(Hash)
100
- end
101
- else
102
- # Simple string value
103
- name = identifier_data
104
- end
110
+ # Resolve an RI/SDI code against the release in effect on +as_of+ (default:
111
+ # today). Dispatches by code shape to the enlisted (DAFECD) or officer
112
+ # (DAFOCD) ri.yml index. Returns nil when the code parses as neither shape,
113
+ # when the resolved index lacks the code, or when +as_of+ precedes the
114
+ # earliest shipped release for the relevant publication.
115
+ def self.find(code, as_of: nil)
116
+ code = code.to_s
117
+ parsed = Parser.new(code).parse
118
+ publication = parsed[:publication]
119
+ return nil unless publication
120
+
121
+ # Key the memo on the RESOLVED release date so equivalent as_of values
122
+ # (nil, the Date, its string form) share one slot.
123
+ effective_date = Releases.effective_date_for(as_of: as_of, publication: publication)
124
+ CODES[[code, effective_date]] ||= begin
125
+ index = Releases.ri_index(as_of: as_of, publication: publication)
126
+ entry = index[parsed[:specific_ri]]
127
+ return nil unless entry
128
+
129
+ suffix = parsed[:suffix]
130
+ # Shredout suffix meaning, only when the directory documents it.
131
+ shredout_name = suffix && entry.dig(:shredouts, suffix)
132
+ name = shredout_name || entry[:name]
133
+ return nil if name.nil?
134
+
135
+ # A documented shredout acronym wins for a shredded code; otherwise the
136
+ # entry's own acronym (which a consumer overlay may set).
137
+ shredout_acronym = suffix && entry.dig(:shredout_acronyms, suffix)
138
+
139
+ Code.new(
140
+ career_group: parsed[:career_group],
141
+ career_field: parsed[:career_field],
142
+ identifier: parsed[:identifier],
143
+ suffix: suffix,
144
+ specific_ri: parsed[:specific_ri],
145
+ name: name,
146
+ acronym: shredout_acronym || entry[:acronym],
147
+ effective_date: effective_date
148
+ )
149
+ end
150
+ end
151
+
152
+ # Resolve the RI/SDI code whose acronym matches +acronym+ (case-insensitive)
153
+ # in the release in effect on +as_of+. Scans the enlisted index first, then
154
+ # the officer index; within an entry the entry's own acronym is tried before
155
+ # its shredout acronyms (a shredout match returns the concrete shredded
156
+ # code). First match wins. Returns nil when no acronym in the resolved
157
+ # release(s) matches (no leakage across document dates).
158
+ def self.find_by_acronym(acronym, as_of: nil)
159
+ acronym = acronym.to_s.upcase
160
+ return nil if acronym.empty?
161
+
162
+ [Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
163
+ index = Releases.ri_index(as_of: as_of, publication: publication)
164
+ index.each do |code, entry|
165
+ return find(code.to_s, as_of: as_of) if entry[:acronym].to_s.upcase == acronym
166
+
167
+ (entry[:shredout_acronyms] || {}).each do |suffix, acr|
168
+ return find("#{code}#{suffix}", as_of: as_of) if acr.to_s.upcase == acronym
105
169
  end
106
170
  end
107
171
  end
108
-
109
- name || "Unknown"
172
+ nil
110
173
  end
111
174
 
112
- def self.find(code)
113
- code = code.to_s
114
- CODES[code] ||= begin
115
- parser = Parser.new(code)
116
- result = parser.parse
117
-
118
- # Return nil if parsing failed or required fields are missing
119
- return nil if result.reject { |_, v| v.nil? }.empty? ||
120
- result[:career_group].nil? ||
121
- result[:career_field].nil? ||
122
- result[:identifier].nil? ||
123
- result[:specific_ri].nil?
124
-
125
- # Find the name by recursively searching the codes hash
126
- name = find_name_recursive(result)
127
-
128
- # Return nil if name is "Unknown" (code not in data)
129
- return nil if name == "Unknown"
130
-
131
- # Add the name to the result
132
- result[:name] = name
175
+ # Walk both publications' RI/SDI indexes for the release in effect on
176
+ # +as_of+, emitting each base code and its shredout-suffixed combinations,
177
+ # and return the Codes whose code starts with +prefix+.
178
+ def self.search(prefix, as_of: nil)
179
+ prefix = prefix.to_s.upcase
133
180
 
134
- Code.new(**result)
181
+ codes = []
182
+ [Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
183
+ index = Releases.ri_index(as_of: as_of, publication: publication)
184
+ index.each do |code, entry|
185
+ code_str = code.to_s
186
+ codes << code_str
187
+ (entry[:shredouts] || {}).each_key do |suffix|
188
+ codes << "#{code_str}#{suffix}"
189
+ end
190
+ end
135
191
  end
192
+
193
+ codes.select { |code| code.start_with?(prefix) }
194
+ .map { |code| find(code, as_of: as_of) }
195
+ .compact
136
196
  end
137
197
 
198
+ # Clears the memoized lookups and resets the versioned release loader. The
199
+ # +lookup+ keyword is accepted for interface parity with Enlisted/Officer;
200
+ # the versioned index is resolved from the load path at lookup time.
138
201
  def self.reset_data(lookup: $LOAD_PATH)
139
- remove_const(:DATA) if const_defined?(:DATA)
140
- const_set(:DATA, data(lookup:))
202
+ Releases.reset!
141
203
  CODES.clear
142
204
  end
143
-
144
- def self.search(prefix)
145
- results = []
146
- prefix = prefix.to_s.upcase
147
- collect_codes_recursive(DATA, "", prefix, results)
148
- results.map { |code| find(code) }.compact
149
- end
150
-
151
- def self.collect_codes_recursive(data, current_code, prefix, results)
152
- return unless data.is_a?(Hash)
153
-
154
- data.each do |key, value|
155
- code = "#{current_code}#{key}"
156
-
157
- if value.is_a?(Hash) && value[:name]
158
- # This is a node with a name and possibly subcategories
159
- results << code if code.start_with?(prefix)
160
- collect_codes_recursive(value[:subcategories], code, prefix, results) if value[:subcategories]
161
- elsif value.is_a?(String)
162
- # This is a leaf node (simple string value)
163
- results << code if code.start_with?(prefix)
164
- elsif value.is_a?(Hash)
165
- # Nested subcategories without a name at this level
166
- collect_codes_recursive(value, current_code, prefix, results)
167
- end
168
- end
169
- end
170
205
  end
171
206
  end
172
207
  end
@@ -4,17 +4,38 @@ require_relative "afsc/ri"
4
4
 
5
5
  module GovCodes
6
6
  module AFSC
7
- def self.find(code)
8
- AFSC::Enlisted.find(code) ||
9
- AFSC::Officer.find(code) ||
10
- AFSC::RI.find(code)
7
+ # Resolve a code as of the classification-directory release in effect on
8
+ # +as_of+ (default: today). +as_of+ applies to every tier: the versioned
9
+ # enlisted (DAFECD) and officer (DAFOCD) AFSC lookups and the versioned
10
+ # RI/SDI lookup (which itself dispatches by code shape to the DAFECD or
11
+ # DAFOCD RI index), each resolved against its own publication.
12
+ def self.find(code, as_of: nil)
13
+ AFSC::Enlisted.find(code, as_of: as_of) ||
14
+ AFSC::Officer.find(code, as_of: as_of) ||
15
+ AFSC::RI.find(code, as_of: as_of)
11
16
  end
12
17
 
13
- def self.search(prefix)
18
+ # Resolve a code by its acronym (case-insensitive) as of the release in
19
+ # effect on +as_of+ (default: today). Searches the enlisted tier
20
+ # (source-verified acronyms + that release's overlay), then the officer
21
+ # tier (specialty and shredout acronyms + that release's overlay), then the
22
+ # versioned RI/SDI tier (enlisted then officer RI index for that release).
23
+ # Returns a single Code or nil.
24
+ #
25
+ # Acronyms are expected to be unique. If two codes carry the same acronym,
26
+ # the first match wins in a defined order: enlisted before officer before RI,
27
+ # and within a tier by the data's key order.
28
+ def self.find_by_acronym(acronym, as_of: nil)
29
+ AFSC::Enlisted.find_by_acronym(acronym, as_of: as_of) ||
30
+ AFSC::Officer.find_by_acronym(acronym, as_of: as_of) ||
31
+ AFSC::RI.find_by_acronym(acronym, as_of: as_of)
32
+ end
33
+
34
+ def self.search(prefix, as_of: nil)
14
35
  results = []
15
- results.concat(Enlisted.search(prefix))
16
- results.concat(Officer.search(prefix))
17
- results.concat(RI.search(prefix))
36
+ results.concat(Enlisted.search(prefix, as_of: as_of))
37
+ results.concat(Officer.search(prefix, as_of: as_of))
38
+ results.concat(RI.search(prefix, as_of: as_of))
18
39
  results
19
40
  end
20
41