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,9 +1,12 @@
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
6
+ # Officer AFSCs resolved against the versioned DAFOCD release index. Mirrors
7
+ # Enlisted: +find(code, as_of:)+ resolves the X-form specialty (e.g. :11BX)
8
+ # or a literal bare code (e.g. :10C0) in the release in effect on +as_of+,
9
+ # defaulting to today.
7
10
  module Officer
8
11
  class Parser
9
12
  def initialize(code)
@@ -53,91 +56,144 @@ module GovCodes
53
56
  end
54
57
  end
55
58
 
56
- extend GovCodes::DataLoader
57
-
58
- DATA = data
59
+ CODES = {}
60
+ private_constant :CODES
59
61
 
60
62
  Code = Data.define(
61
63
  :prefix,
62
64
  :career_group,
63
65
  :functional_area,
64
66
  :qualification_level,
67
+ :qualification_level_number,
68
+ :qualification_level_name,
65
69
  :shredout,
70
+ :shredout_name,
71
+ :specialty,
72
+ :specialty_name,
66
73
  :specific_afsc,
67
- :name
74
+ :name,
75
+ :acronym,
76
+ :effective_date
68
77
  )
69
78
 
70
- def self.find_name_recursive(result)
71
- name = nil
72
- data = DATA
73
-
74
- # For officer codes, build the lookup key from career group + functional area + qual level
75
- # like "11BX" where "11" is career group, "B" is functional area, "X" is qual level
76
- career_group = result[:career_group].to_s
77
- functional_area = result[:functional_area].to_s
78
- qual_level = result[:qualification_level].to_s
79
- combined_key = :"#{career_group}#{functional_area}#{qual_level}"
80
-
81
- # Look for the full code (e.g., "11BX", "11MX")
82
- if data[combined_key]
83
- name = data[combined_key][:name]
84
- data = data[combined_key][:subcategories]
85
-
86
- # Then look for shredout
87
- if data && result[:shredout]
88
- shred = result[:shredout]
89
- # Try Symbol first (most shredouts are letters), then Integer, then String
90
- lookup_value = data[shred] || data[shred.to_s.to_i] || data[shred.to_s]
91
- if lookup_value
92
- name = lookup_value.is_a?(Hash) ? (lookup_value[:name] || name) : (lookup_value || name)
93
- end
79
+ # Resolve an officer AFSC against the DAFOCD release in effect on +as_of+
80
+ # (default: today). Returns nil when the code does not parse, when neither
81
+ # the X-form specialty nor a literal bare code resolves in the release, or
82
+ # when +as_of+ precedes the earliest shipped release (or no release has
83
+ # taken effect yet).
84
+ def self.find(code, as_of: nil)
85
+ code = code.to_s
86
+ # Key the memo on the RESOLVED release date so equivalent as_of values
87
+ # (nil, the Date, its string form, any date in the same release window)
88
+ # share one slot instead of growing unbounded for time-series callers.
89
+ effective_date = Releases.effective_date_for(as_of: as_of, publication: Releases::OFFICER_PUBLICATION)
90
+ CODES[[code, effective_date]] ||= begin
91
+ result = Parser.new(code).parse
92
+
93
+ # Return nil if parsing failed or required fields are missing
94
+ return nil if result.reject { |_, v| v.nil? }.empty? ||
95
+ result[:career_group].nil? ||
96
+ result[:functional_area].nil? ||
97
+ result[:qualification_level].nil? ||
98
+ result[:specific_afsc].nil?
99
+
100
+ index = Releases.officer_index(as_of: as_of)
101
+
102
+ # Prefer the X-form specialty key (11B3 -> :11BX). Fall back to the
103
+ # literal 4-char code for bare specialties keyed that way (:10C0, :62S0,
104
+ # :63G0, :63S0). By design these bare-code specialties resolve ONLY via
105
+ # their literal code -- there is no X-form ladder for them, so
106
+ # find("10CX")/find("62SX")/find("63GX")/find("63SX") return nil while
107
+ # find("10C0")/find("62S0")/find("63G0")/find("63S0") resolve.
108
+ specialty = :"#{result[:career_group]}#{result[:functional_area]}X"
109
+ if !index.key?(specialty) && index.key?(result[:specific_afsc])
110
+ specialty = result[:specific_afsc]
94
111
  end
95
- end
96
-
97
- name || "Unknown"
98
- end
99
112
 
100
- def self.find(code)
101
- code = code.to_s
102
- parser = Parser.new(code)
103
- result = parser.parse
113
+ entry = index[specialty]
114
+ return nil unless entry
115
+
116
+ result[:specialty] = specialty
117
+ result[:specialty_name] = entry[:name]
118
+
119
+ # Qualification-level title: numeric level (1-4) maps to the directory's
120
+ # per-specialty title; a letter (X/Y/Z) or an absent level leaves it nil.
121
+ result[:qualification_level_number] = nil
122
+ result[:qualification_level_name] = nil
123
+ qual = result[:qualification_level].to_s
124
+ if qual.match?(/\d/)
125
+ number = Integer(qual)
126
+ result[:qualification_level_number] = number
127
+ result[:qualification_level_name] = entry.dig(:qual_levels, number, :title)
128
+ end
104
129
 
105
- return nil if result.reject { |_, v| v.nil? }.empty?
130
+ # Shredout meaning, only when the directory documents this shredout.
131
+ shredout = result[:shredout]
132
+ shredout_name = shredout && entry.dig(:shredouts, shredout)
133
+ result[:shredout_name] = shredout_name
106
134
 
107
- # Find the name by recursively searching the codes hash
108
- name = find_name_recursive(result)
135
+ name = shredout_name || entry[:name]
136
+ return nil if name.nil?
137
+ result[:name] = name
109
138
 
110
- # Add the name to the result
111
- result[:name] = name
139
+ # Acronym: a documented shredout acronym wins for a shredded code;
140
+ # otherwise the specialty acronym (which a consumer overlay may set).
141
+ shredout_acronym = shredout && entry.dig(:shredout_acronyms, shredout)
142
+ result[:acronym] = shredout_acronym || entry[:acronym]
143
+ result[:effective_date] = effective_date
112
144
 
113
- Code.new(**result)
145
+ Code.new(**result)
146
+ end
114
147
  end
115
148
 
116
- def self.search(prefix)
117
- results = []
118
- prefix = prefix.to_s.upcase
119
- collect_codes_recursive(DATA, "", prefix, results)
120
- results.map { |code| find(code) }.compact
149
+ # Resolve the officer specialty/shredout whose acronym matches +acronym+
150
+ # (case-insensitive) in the release in effect on +as_of+. Searches both
151
+ # specialty acronyms and shredout acronyms; a shredout match returns the
152
+ # concrete shredded code (so "TACPO" resolves find("19ZXB")). First match
153
+ # wins: entries are scanned in index order and, within an entry, the
154
+ # specialty acronym is tried before its shredout acronyms. Returns nil when
155
+ # no acronym in the resolved release matches (no leakage across dates).
156
+ def self.find_by_acronym(acronym, as_of: nil)
157
+ acronym = acronym.to_s.upcase
158
+ return nil if acronym.empty?
159
+
160
+ index = Releases.officer_index(as_of: as_of)
161
+ index.each do |specialty, entry|
162
+ return find(specialty.to_s, as_of: as_of) if entry[:acronym].to_s.upcase == acronym
163
+
164
+ (entry[:shredout_acronyms] || {}).each do |shredout, acr|
165
+ return find("#{specialty}#{shredout}", as_of: as_of) if acr.to_s.upcase == acronym
166
+ end
167
+ end
168
+ nil
121
169
  end
122
170
 
123
- def self.collect_codes_recursive(data, current_code, prefix, results)
124
- return unless data.is_a?(Hash)
125
-
126
- data.each do |key, value|
127
- code = "#{current_code}#{key}"
128
-
129
- if value.is_a?(Hash) && value[:name]
130
- # This is a node with a name and possibly subcategories
131
- results << code if code.start_with?(prefix)
132
- collect_codes_recursive(value[:subcategories], code, prefix, results) if value[:subcategories]
133
- elsif value.is_a?(String)
134
- # This is a leaf node (simple string value)
135
- results << code if code.start_with?(prefix)
136
- elsif value.is_a?(Hash)
137
- # Nested subcategories without a name at this level
138
- collect_codes_recursive(value, current_code, prefix, results)
171
+ # Walk the resolved index emitting each specialty code and its
172
+ # specialty+shredout combinations, returning the Codes matching +prefix+.
173
+ def self.search(prefix, as_of: nil)
174
+ prefix = prefix.to_s.upcase
175
+ index = Releases.officer_index(as_of: as_of)
176
+
177
+ codes = []
178
+ index.each do |specialty, entry|
179
+ specialty_code = specialty.to_s
180
+ codes << specialty_code
181
+ (entry[:shredouts] || {}).each_key do |shredout|
182
+ codes << "#{specialty_code}#{shredout}"
139
183
  end
140
184
  end
185
+
186
+ codes.select { |code| code.start_with?(prefix) }
187
+ .map { |code| find(code, as_of: as_of) }
188
+ .compact
189
+ end
190
+
191
+ # Clears the memoized lookups and resets the versioned release loader. The
192
+ # +lookup+ keyword is accepted for interface parity with Enlisted/RI; the
193
+ # versioned index is resolved from the load path at lookup time.
194
+ def self.reset_data(lookup: $LOAD_PATH)
195
+ Releases.reset!
196
+ CODES.clear
141
197
  end
142
198
  end
143
199
  end