gov_codes 0.1.0 → 0.1.1

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.
@@ -0,0 +1,172 @@
1
+ require "strscan"
2
+ require "yaml"
3
+ require_relative "../data_loader"
4
+
5
+ module GovCodes
6
+ 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")
12
+ #
13
+ # Examples: 9Z200, 8A400, 8G000B, 8R300A
14
+ module RI
15
+ class Parser
16
+ def initialize(code)
17
+ @code = code
18
+ end
19
+
20
+ def parse
21
+ scanner = StringScanner.new(@code.to_s)
22
+ result = {
23
+ career_group: nil,
24
+ career_field: nil,
25
+ identifier: nil,
26
+ suffix: nil,
27
+ specific_ri: nil
28
+ }
29
+
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)
49
+ 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
+
55
+ result
56
+ end
57
+ end
58
+
59
+ extend GovCodes::DataLoader
60
+
61
+ DATA = data
62
+
63
+ Code = Data.define(
64
+ :career_group,
65
+ :career_field,
66
+ :identifier,
67
+ :suffix,
68
+ :specific_ri,
69
+ :name
70
+ )
71
+
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
105
+ end
106
+ end
107
+ end
108
+
109
+ name || "Unknown"
110
+ end
111
+
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
133
+
134
+ Code.new(**result)
135
+ end
136
+ end
137
+
138
+ def self.reset_data(lookup: $LOAD_PATH)
139
+ remove_const(:DATA) if const_defined?(:DATA)
140
+ const_set(:DATA, data(lookup:))
141
+ CODES.clear
142
+ 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
+ end
171
+ end
172
+ end
@@ -0,0 +1,237 @@
1
+ # Reporting Identifiers (RI) and Special Duty Identifiers (SDI)
2
+ # Source: https://en.wikipedia.org/wiki/Air_Force_Specialty_Code
3
+ # Wikipedia Revision: 1318430021
4
+ # Extracted: 2025-11-17
5
+ #
6
+ # IMPORTANT: This file contains ONLY codes explicitly listed on Wikipedia.
7
+ # No predictions, no assumptions, no hallucinations.
8
+ # See .agent-os/product/decisions.md (DEC-003) for anti-hallucination policy.
9
+ #
10
+ # Format: Career field (digit + letter) + identifier (3 digits) + optional suffix
11
+ # Examples: 8A400, 9Z200, 8G000B, 8R300A
12
+
13
+ :8A:
14
+ :name: Enlisted aide and protocol
15
+ :subcategories:
16
+ :200: Enlisted aide
17
+ :300: Protocol
18
+ :400: Talent management consultant
19
+ :8B:
20
+ :name: Military training
21
+ :subcategories:
22
+ :000: Military training instructor
23
+ :100: Military Training leader
24
+ :200: Academy military training NCO
25
+ :300: Officer accessions instructor
26
+ :8C:
27
+ :name: Military and family readiness
28
+ :subcategories:
29
+ :000: Military and family readiness non-commissioned officer (RNCO)
30
+ :8D:
31
+ :name: Language and culture
32
+ :subcategories:
33
+ :100: Language & culture advisor
34
+ :8F:
35
+ :name: First sergeant
36
+ :subcategories:
37
+ :000: First sergeant
38
+ :8G:
39
+ :name: Honor guard
40
+ :subcategories:
41
+ :000:
42
+ :name: Premier honor guard
43
+ :subcategories:
44
+ :B: Pallbearer
45
+ :C: Color guard
46
+ :D: Drill team
47
+ :100: Base honor guard program manager
48
+ :8H:
49
+ :name: Airmen dorm leader
50
+ :subcategories:
51
+ :000: Airmen dorm leader
52
+ :8I:
53
+ :name: Inspector general
54
+ :subcategories:
55
+ :000: Superintendent, inspector general
56
+ :100: Inspections coordinator
57
+ :200: Complaints & resolution coordinator
58
+ :8K:
59
+ :name: Software development
60
+ :subcategories:
61
+ :000: Software development specialist
62
+ :8L:
63
+ :name: Air advisor
64
+ :subcategories:
65
+ :100: Air advisor basic
66
+ :200: Air advisor basic, team sergeant
67
+ :300: Air advisor basic, team leader
68
+ :400: Air advisor advanced
69
+ :500: Air advisor advanced, team sergeant
70
+ :600: Air advisor advanced, team leader
71
+ :700: Combat aviation advisor
72
+ :800: Combat aviation advisor team sergeant
73
+ :900: Combat aviation advisor team leader
74
+ :8P:
75
+ :name: Courier and defense attaché
76
+ :subcategories:
77
+ :000: Courier
78
+ :100: Defense attaché
79
+ :8R:
80
+ :name: Recruiting
81
+ :subcategories:
82
+ :000: Enlisted accessions recruiter
83
+ :200: Second-tier recruiter
84
+ :300:
85
+ :name: Third-tier recruiter
86
+ :subcategories:
87
+ :A: Flight chief
88
+ :B: Graduated, flight chief
89
+ :C: Production superintendent
90
+ :E: Senior enlisted leader
91
+ :8S:
92
+ :name: Missile facility
93
+ :subcategories:
94
+ :000: Missile facility manager
95
+ :200: Combat crew communications
96
+ :8T:
97
+ :name: Professional military education
98
+ :subcategories:
99
+ :000: Professional military education instructor
100
+ :100: Enlisted professional military education instructional system designer
101
+ :200: Development advisor
102
+ :8U:
103
+ :name: Unit deployment
104
+ :subcategories:
105
+ :000: Unit deployment manager
106
+ :100: Weapons of mass destruction civil support team (WMD-CST)
107
+ :8W:
108
+ :name: Weapons safety
109
+ :subcategories:
110
+ :000: Weapons safety manager – wing level
111
+ :8Y:
112
+ :name: Pathfinder
113
+ :subcategories:
114
+ :000: Pathfinder
115
+ :9A:
116
+ :name: Disqualified/awaiting separation
117
+ :subcategories:
118
+ :000: Enlisted airman/guardian – disqualified for reasons beyond control
119
+ :100: Enlisted airman/guardian – disqualified for reasons within control
120
+ :200: Enlisted airman/guardian awaiting discharge, separation, or retirement for reasons within their control
121
+ :300: Enlisted airman/guardian awaiting discharge, separation, or retirement for reasons beyond their control
122
+ :400: Disqualified airman/guardian, return to duty program
123
+ :500: Enlisted airman/guardian temporarily ineligible for retraining – disqualified for reasons beyond control
124
+ :9B:
125
+ :name: Senior enlisted advisor
126
+ :subcategories:
127
+ :000: Senior enlisted advisor to the chairman of the joint chiefs of staff
128
+ :100: Senior enlisted advisor to the chief of the National Guard Bureau
129
+ :9C:
130
+ :name: Chief Master Sergeant of the Air Force
131
+ :subcategories:
132
+ :000: Chief Master Sergeant of the Air Force
133
+ :100: Executive assistant to the Chief Master Sergeant of the Air Force
134
+ :9D:
135
+ :name: Developmental positions
136
+ :subcategories:
137
+ :100: AF developmental senior enlisted positions
138
+ :200: Key developmental senior enlisted positions
139
+ :9E:
140
+ :name: Command Chief Master Sergeant
141
+ :subcategories:
142
+ :000: Command Chief Master Sergeant
143
+ :100: Command chief executive assistant
144
+ :200: Individual Mobilization Augmentee to Command Chief Master Sergeant
145
+ :9F:
146
+ :name: First term airmen center
147
+ :subcategories:
148
+ :000: First term airmen center (FTAC) NCOIC
149
+ :9G:
150
+ :name: Group senior enlisted leader
151
+ :subcategories:
152
+ :100: Group senior enlisted leader
153
+ :9H:
154
+ :name: Academic and WHCA
155
+ :subcategories:
156
+ :000: Academic faculty instructor
157
+ :100: White House communications agency technician (WHCA)
158
+ :9I:
159
+ :name: Futures airmen
160
+ :subcategories:
161
+ :000: Futures airmen
162
+ :9J:
163
+ :name: Prisoner
164
+ :subcategories:
165
+ :000: Prisoner
166
+ :9L:
167
+ :name: Interpreter/translator
168
+ :subcategories:
169
+ :000: Interpreter/translator
170
+ :100: Enlisted international affairs manager
171
+ :9M:
172
+ :name: Military entrance and health
173
+ :subcategories:
174
+ :000: Military entrance processing command (MEPCOM) senior enlisted advisor
175
+ :200: International health specialists (IHS)
176
+ :400: Chief, medical enlisted force (CMEF)
177
+ :9N:
178
+ :name: Legislative fellows
179
+ :subcategories:
180
+ :000: Secretary of the Air Force enlisted legislative fellows
181
+ :9P:
182
+ :name: Patient
183
+ :subcategories:
184
+ :000: Patient
185
+ :9Q:
186
+ :name: Reserve force
187
+ :subcategories:
188
+ :000: Reserve force generation and oversight NCO
189
+ :9R:
190
+ :name: Civil Air Patrol
191
+ :subcategories:
192
+ :000: Civil Air Patrol (CAP) – USAF reserve assistance NCO
193
+ :9S:
194
+ :name: Scientific applications
195
+ :subcategories:
196
+ :100: Scientific applications specialist
197
+ :9T:
198
+ :name: Trainee
199
+ :subcategories:
200
+ :000: Basic enlisted airman
201
+ :100: Officer trainee
202
+ :200: Precadet assignee
203
+ :400: AFIT/EWI enlisted student
204
+ :500: Basic special warfare enlisted airman
205
+ :9U:
206
+ :name: Ineligible/unallotted
207
+ :subcategories:
208
+ :000: Enlisted airman/guardian ineligible for local utilization
209
+ :100: Unallotted enlisted authorization
210
+ :9V:
211
+ :name: Joint senior enlisted
212
+ :subcategories:
213
+ :000: Key developmental joint senior enlisted position
214
+ :100: Executive assistant to the senior enlisted advisor to the Chairman of the Joint Chiefs of Staff
215
+ :9W:
216
+ :name: Wounded warrior
217
+ :subcategories:
218
+ :000: Combat wounded warrior
219
+ :100: Reserved for future use
220
+ :200: Combat wounded warrior with exemptions
221
+ :300: Non-combat wounded warrior
222
+ :400: Wounded warrior – limited assignment status (LAS)
223
+ :500: Reserved for future use
224
+ :600: Reserved for future use
225
+ :700: Reserved for future use
226
+ :800: Wounded warrior – ambassador
227
+ :900: Wounded warrior – project planner/officer
228
+ :9Y:
229
+ :name: Air Force parachute team
230
+ :subcategories:
231
+ :000: Air force parachute team (AFPT) instructor
232
+ :9Z:
233
+ :name: Special warfare mission support
234
+ :subcategories:
235
+ :000: Special warfare mission support (SWMS) career field manager (CFM) on headquarters Air Force staff, Air Force Special Warfare Division
236
+ :100: Special warfare mission support (SWMS) senior enlisted leader, Air Force Special Warfare (AFSPECWAR)
237
+ :200: Special warfare mission support (SWMS) superintendent, Air Force Special Warfare (AFSPECWAR)
@@ -1,16 +1,27 @@
1
1
  require_relative "afsc/enlisted"
2
2
  require_relative "afsc/officer"
3
+ require_relative "afsc/ri"
3
4
 
4
5
  module GovCodes
5
6
  module AFSC
6
7
  def self.find(code)
7
8
  AFSC::Enlisted.find(code) ||
8
- AFSC::Officer.find(code)
9
+ AFSC::Officer.find(code) ||
10
+ AFSC::RI.find(code)
11
+ end
12
+
13
+ def self.search(prefix)
14
+ results = []
15
+ results.concat(Enlisted.search(prefix))
16
+ results.concat(Officer.search(prefix))
17
+ results.concat(RI.search(prefix))
18
+ results
9
19
  end
10
20
 
11
21
  def self.reset_data(lookup: $LOAD_PATH)
12
22
  Enlisted.reset_data(lookup:)
13
23
  Officer.reset_data(lookup:)
24
+ RI.reset_data(lookup:)
14
25
  end
15
26
  end
16
27
  end
@@ -11,65 +11,59 @@ module GovCodes
11
11
 
12
12
  def data(lookup: $LOAD_PATH)
13
13
  data = {}
14
+ lookup_array = Array(lookup)
15
+ return data if lookup_array.empty?
14
16
 
15
- namespace_parts = name.split("::")
16
- .map { |it| it.gsub(/([A-Z])([a-z])/, '_\1\2').downcase }
17
- .map { |it| it.sub(/^_/, "") }
17
+ # Add the gem's lib directory to the lookup path
18
+ gem_lib_dir = File.expand_path("..", __dir__)
19
+ lookup_paths = [gem_lib_dir] + lookup_array
18
20
 
19
- # Append .yml to the last item
21
+ # Convert namespace to file path parts (e.g., "GovCodes::AFSC::Enlisted" -> ["gov_codes", "afsc", "enlisted.yml"])
22
+ namespace_parts = name.split("::")
23
+ .map { |part| part.gsub(/([A-Z])([a-z])/, '_\1\2').downcase.sub(/^_/, "") }
20
24
  namespace_parts[-1] = "#{namespace_parts[-1]}.yml"
21
25
 
22
- # Iterate through each path in the lookup array
23
- files = lookup.map do |dir|
26
+ # Find all existing YAML files in lookup paths
27
+ lookup_paths.filter_map do |dir|
24
28
  yaml_path = File.join(dir, *namespace_parts)
25
29
  yaml_path if File.exist?(yaml_path)
26
- end
27
- .compact
28
- .uniq
29
- files.each do |path|
30
- data.merge!(YAML.load_file(path, symbolize_names: true))
30
+ end.uniq.each do |path|
31
+ yaml_data = YAML.load_file(path, symbolize_names: true)
32
+ data.merge!(yaml_data) if yaml_data.is_a?(Hash)
33
+ rescue Psych::SyntaxError, TypeError
34
+ # Handle invalid YAML gracefully
35
+ next
31
36
  end
32
37
 
33
38
  data
34
39
  end
35
40
 
36
41
  def reset_data(lookup: $LOAD_PATH)
37
- remove_const(:DATA)
42
+ remove_const(:DATA) if const_defined?(:DATA, false)
38
43
  const_set(:DATA, data(lookup:).freeze)
39
- remove_const(:CODES)
44
+ remove_const(:CODES) if const_defined?(:CODES, false)
40
45
  const_set(:CODES, {})
41
46
  end
42
47
 
43
48
  def find_name_recursive(result)
44
- # Start with the career field (e.g., "1N")
45
49
  base_code = result[:career_field].to_sym
50
+ base_data = self::DATA[base_code]
51
+ return "Unknown" unless base_data
46
52
 
47
- loaded_data = self::DATA
48
- # Look up in the codes hash
49
- if loaded_data[base_code]
50
- # If we have a subcategory, try to find a more specific name
51
- if result[:subcategory] &&
52
- loaded_data.dig(base_code, :subcategories) &&
53
- loaded_data.dig(base_code, :subcategories, result[:subcategory])
54
-
55
- subdivision = loaded_data.dig(base_code, :subcategories, result[:subcategory])
56
- # If we have a shredout, try to find an even more specific name
57
- if result[:shredout] &&
58
- subdivision.dig(:subcategories) &&
59
- subdivision.dig(:subcategories, result[:shredout])
60
- return subdivision.dig(:subcategories, result[:shredout], :name)
53
+ # Try subcategory lookup if present
54
+ if result[:subcategory]
55
+ subdivision = base_data.dig(:subcategories, result[:subcategory])
56
+ if subdivision
57
+ # Try shredout lookup if present
58
+ if result[:shredout]
59
+ shredout_name = subdivision.dig(:subcategories, result[:shredout], :name)
60
+ return shredout_name if shredout_name
61
61
  end
62
-
63
- # Return the subdivision name if no shredout match
64
- return subdivision.dig(:name)
62
+ return subdivision[:name] if subdivision[:name]
65
63
  end
66
-
67
- # Return the base name if no subdivision match
68
- return loaded_data.dig(base_code, :name)
69
64
  end
70
65
 
71
- # Return a default if no match found
72
- "Unknown"
66
+ base_data[:name] || "Unknown"
73
67
  end
74
68
  end
75
69
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GovCodes
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gov_codes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -22,11 +22,15 @@ files:
22
22
  - CHANGELOG.md
23
23
  - README.md
24
24
  - Rakefile
25
+ - checksums/gov_codes-0.1.0.gem.sha512
25
26
  - lib/gov_codes.rb
26
27
  - lib/gov_codes/afsc.rb
27
28
  - lib/gov_codes/afsc/enlisted.rb
28
29
  - lib/gov_codes/afsc/enlisted.yml
29
30
  - lib/gov_codes/afsc/officer.rb
31
+ - lib/gov_codes/afsc/officer.yml
32
+ - lib/gov_codes/afsc/ri.rb
33
+ - lib/gov_codes/afsc/ri.yml
30
34
  - lib/gov_codes/data_loader.rb
31
35
  - lib/gov_codes/version.rb
32
36
  - sig/gov_codes.rbs
@@ -50,7 +54,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
50
54
  - !ruby/object:Gem::Version
51
55
  version: '0'
52
56
  requirements: []
53
- rubygems_version: 3.6.7
57
+ rubygems_version: 3.7.2
54
58
  specification_version: 4
55
59
  summary: Handle codes used by the US government.
56
60
  test_files: []