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
@@ -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)
@@ -17,7 +20,8 @@ module GovCodes
17
20
  career_group: nil,
18
21
  functional_area: nil,
19
22
  qualification_level: nil,
20
- shredout: nil
23
+ shredout: nil,
24
+ specific_afsc: nil
21
25
  }
22
26
 
23
27
  # Scan for prefix (optional)
@@ -26,20 +30,24 @@ module GovCodes
26
30
  # Scan for career group (two digits)
27
31
  career_group = scanner.scan(/\d{2}/)
28
32
  return result unless career_group
29
- result[:career_group] = career_group
33
+ result[:career_group] = career_group.to_sym
30
34
 
31
35
  # Scan for functional area (uppercase letter)
32
36
  functional_area = scanner.scan(/[A-Z]/)
33
37
  return result unless functional_area
34
- result[:functional_area] = functional_area
38
+ result[:functional_area] = functional_area.to_sym
35
39
 
36
- # Scan for qualification level (digit 0-4)
37
- qualification_level = scanner.scan(/[0-4]/)
40
+ # Scan for qualification level (digit 0-4 or letter X-Z)
41
+ qualification_level = scanner.scan(/[0-4A-Z]/)
38
42
  return result unless qualification_level
39
- result[:qualification_level] = qualification_level
43
+ result[:qualification_level] = qualification_level.to_sym
44
+
45
+ # Build specific AFSC
46
+ result[:specific_afsc] = :"#{result[:career_group]}#{result[:functional_area]}#{result[:qualification_level]}"
40
47
 
41
48
  # Scan for shredout (optional)
42
- result[:shredout] = scanner.scan(/[A-Z]/)
49
+ shredout = scanner.scan(/[A-Z]/)
50
+ result[:shredout] = shredout&.to_sym
43
51
 
44
52
  # Check if we've reached the end of the string
45
53
  return result unless scanner.eos?
@@ -48,36 +56,144 @@ module GovCodes
48
56
  end
49
57
  end
50
58
 
51
- extend GovCodes::DataLoader
52
- DATA = data
59
+ CODES = {}
60
+ private_constant :CODES
53
61
 
54
62
  Code = Data.define(
55
63
  :prefix,
56
64
  :career_group,
57
65
  :functional_area,
58
66
  :qualification_level,
67
+ :qualification_level_number,
68
+ :qualification_level_name,
59
69
  :shredout,
60
- :name
70
+ :shredout_name,
71
+ :specialty,
72
+ :specialty_name,
73
+ :specific_afsc,
74
+ :name,
75
+ :acronym,
76
+ :effective_date
61
77
  )
62
78
 
63
- def self.find(code)
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)
64
85
  code = code.to_s
65
- parser = Parser.new(code)
66
- result = parser.parse
67
-
68
- return nil if result.reject { |_, v| v.nil? }.empty?
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]
111
+ end
112
+
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
129
+
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
134
+
135
+ name = shredout_name || entry[:name]
136
+ return nil if name.nil?
137
+ result[:name] = name
138
+
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
144
+
145
+ Code.new(**result)
146
+ end
147
+ end
69
148
 
70
- # Find the name from the codes data
71
- career_group = result[:career_group]
72
- functional_area = result[:functional_area]
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
169
+ end
73
170
 
74
- # Look up the name in the codes hash
75
- name = find_name(career_group, functional_area)
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}"
183
+ end
184
+ end
76
185
 
77
- # Add the name to the result
78
- result[:name] = name
186
+ codes.select { |code| code.start_with?(prefix) }
187
+ .map { |code| find(code, as_of: as_of) }
188
+ .compact
189
+ end
79
190
 
80
- Code.new(**result)
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
81
197
  end
82
198
  end
83
199
  end