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.
- checksums.yaml +4 -4
- data/.simplecov +15 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +57 -2
- data/README.md +223 -22
- data/Rakefile +7 -1
- data/checksums/gov_codes-0.1.0.gem.sha512 +1 -0
- data/checksums/gov_codes-0.1.1.gem.sha512 +1 -0
- data/lib/gov_codes/afsc/enlisted.rb +134 -24
- data/lib/gov_codes/afsc/officer.rb +141 -25
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +2726 -0
- data/lib/gov_codes/afsc/releases/dafecd/2025-10-31/ri.yml +369 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/officer.yml +2393 -0
- data/lib/gov_codes/afsc/releases/dafocd/2025-10-31/ri.yml +193 -0
- data/lib/gov_codes/afsc/releases.rb +219 -0
- data/lib/gov_codes/afsc/releases.yml +11 -0
- data/lib/gov_codes/afsc/ri.rb +207 -0
- data/lib/gov_codes/afsc.rb +35 -3
- data/lib/gov_codes/dafecd/index_builder.rb +416 -0
- data/lib/gov_codes/dafecd/patterns.rb +50 -0
- data/lib/gov_codes/dafecd/publication.rb +252 -0
- data/lib/gov_codes/dafecd/record_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/change_date.rb +63 -0
- data/lib/gov_codes/dafecd/ri_sdi/config.rb +137 -0
- data/lib/gov_codes/dafecd/ri_sdi/index_builder.rb +265 -0
- data/lib/gov_codes/dafecd/ri_sdi/ri_list_parser.rb +125 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_card_parser.rb +151 -0
- data/lib/gov_codes/dafecd/ri_sdi/sdi_section_splitter.rb +77 -0
- data/lib/gov_codes/dafecd/ri_sdi/section_slicer.rb +45 -0
- data/lib/gov_codes/dafecd/ri_sdi/title.rb +76 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafecd.yml +124 -0
- data/lib/gov_codes/dafecd/ri_sdi/title_overrides/dafocd.yml +78 -0
- data/lib/gov_codes/dafecd/shredout_acronyms.rb +47 -0
- data/lib/gov_codes/dafecd/shredout_degluer.rb +73 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafecd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_overrides/dafocd.yml +27 -0
- data/lib/gov_codes/dafecd/shredout_parser.rb +75 -0
- data/lib/gov_codes/dafecd/specialty_parser.rb +200 -0
- data/lib/gov_codes/dafecd/text.rb +48 -0
- data/lib/gov_codes/dafecd/title_degluer.rb +67 -0
- data/lib/gov_codes/dafecd/title_overrides/dafocd.yml +141 -0
- data/lib/gov_codes/dafecd/title_overrides.yml +142 -0
- data/lib/gov_codes/data_loader.rb +49 -36
- data/lib/gov_codes/version.rb +1 -1
- metadata +36 -4
- data/lib/gov_codes/afsc/enlisted.yml +0 -725
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11433a91ce2be5af9141fa58c9110a9d0f383b9769550f2fc95843b27d32da6b
|
|
4
|
+
data.tar.gz: 49d09edca49ae863444781be3da6eff64cffa2f499b8442d002736e7dc8624be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dc82c96d74319e22b9d24d8ecc2f9c827eec171921f28158118fde4dc3586dcaa1ca5c06bd0827b954e5e659e5471eed404e7b704ad1d60f8231d9df6857e17
|
|
7
|
+
data.tar.gz: 4091a9fe8b0e0922817a01aeaaf0c48faf88339fb80431b7556975fa41c0e0c4445cc422dbcae782b91a1a8a1a4414d8ca3de8894b33af5a0742050bb73e4e3a
|
data/.simplecov
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
# Set command name before starting to ensure consistent tracking
|
|
2
|
+
SimpleCov.command_name "Unit Tests"
|
|
3
|
+
|
|
1
4
|
SimpleCov.start do
|
|
2
5
|
# enable_coverage :branch
|
|
3
6
|
|
|
4
7
|
# Add any files or directories you want to exclude from coverage
|
|
5
|
-
add_filter "/test/"
|
|
8
|
+
add_filter "/test/"
|
|
9
|
+
add_filter "lib/gov_codes/version.rb"
|
|
6
10
|
|
|
7
11
|
# Set minimum coverage requirements
|
|
8
12
|
# minimum_coverage 80
|
|
@@ -12,4 +16,14 @@ SimpleCov.start do
|
|
|
12
16
|
|
|
13
17
|
# Group files by module
|
|
14
18
|
add_group "AFSC", "lib/gov_codes/afsc"
|
|
19
|
+
|
|
20
|
+
# Add JSON formatter for easier parsing
|
|
21
|
+
require "simplecov_json_formatter"
|
|
22
|
+
formatter SimpleCov::Formatter::MultiFormatter.new([
|
|
23
|
+
SimpleCov::Formatter::HTMLFormatter,
|
|
24
|
+
SimpleCov::Formatter::JSONFormatter
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
# Enable result merging
|
|
28
|
+
enable_coverage_for_eval
|
|
15
29
|
end
|
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby
|
|
1
|
+
ruby 4.0.5
|
data/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,63 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
8
|
+
## [0.1.2] - 2026-07-16
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- 6C0X1 Contracting (4a6c87e)
|
|
13
|
+
- 6F0X1 Financial Management and Comptroller (4a6c87e)
|
|
14
|
+
- 7S0X1 Special Investigations (4a6c87e)
|
|
15
|
+
- 5Z700/5Z800/5Z900 Chief Enlisted Manager codes (4a6c87e)
|
|
16
|
+
- Concrete skill-level AFSC code lookup (e.g. 1A172Y) (1cea6af)
|
|
17
|
+
- Version-aware lookup via find(code, as_of:) with effective_date (1cea6af)
|
|
18
|
+
- Officer AFSC lookups versioned by DAFOCD release with as_of (1a43a2d)
|
|
19
|
+
- Officer qualification-level titles and specialty/shredout acronyms (e.g. TACPO) (1a43a2d)
|
|
20
|
+
- Officer specialties 17DX, 44BX, 45AX, 47BX, 47PX, 48GX from the DAFOCD (b28f308)
|
|
21
|
+
- RI/SDI extraction pipeline (parser, index builder, CLI) for DAFECD and DAFOCD (5d068dd)
|
|
22
|
+
- versioned RI/SDI release data at releases/{dafecd,dafocd}/2025-10-31/ri.yml (de03f09)
|
|
23
|
+
- RI.find_by_acronym as a real reverse lookup; as_of threading through RI.find and RI.search (712f624)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Set dependabot cooldown to 14 days (fde4b9a)
|
|
28
|
+
- Test against Ruby 4.0.5 (61983ce)
|
|
29
|
+
- Source enlisted AFSC data from the official DAFECD instead of Wikipedia (1cea6af)
|
|
30
|
+
- Source officer AFSC data from the official DAFOCD instead of Wikipedia (1a43a2d)
|
|
31
|
+
- as_of defaults to today, not the newest release regardless of date (ff86c00)
|
|
32
|
+
- RI resolves from the versioned DAFECD/DAFOCD release indexes instead of Wikipedia-sourced data (712f624)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- SimpleCov reporting inaccurate coverage due to at_exit ordering (de7063c)
|
|
37
|
+
- glued shredout names in the enlisted release data (be773cf)
|
|
38
|
+
- required_ruby_version understated the actual floor (b8d64b8)
|
|
39
|
+
- officer ladder missed no-comma, glued-shred, and glyph-prefixed cards (b28f308)
|
|
40
|
+
|
|
41
|
+
## [0.1.1] - 2025-11-17
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- AFSC Officer support (bb96aaa)
|
|
46
|
+
- Data extractor script for Wikipedia (c8ec55b)
|
|
47
|
+
- 1Z Special Warfare career field (Pararescue, Combat Control, TACP) (4ca6aef)
|
|
48
|
+
- Nokogiri gem for HTML parsing (4ca6aef)
|
|
49
|
+
- Reporting identifiers with GovCodes::AFSC::RI (6102656)
|
|
50
|
+
- AFSC.search to return an array of matching codes for the given prefix. (5c5bdf1)
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Test against Ruby 3.3.8 and 3.4.3
|
|
55
|
+
- Officer YAML structure to match Wikipedia (11BX, 11MX, etc.) (4ca6aef)
|
|
56
|
+
- Officer parser to accept letter qualification levels (X, Y, Z) (4ca6aef)
|
|
57
|
+
- Officer lookup to include qualification level in key (4ca6aef)
|
|
58
|
+
- Enlisted/Officer lookups to handle String leaf values (4ca6aef)
|
|
59
|
+
- Use git trailers to track changelog changes. (c83929a)
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
- 824 hallucinated codes not found in Wikipedia (4ca6aef)
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- Tests updated to use real Wikipedia codes (4ca6aef)
|
data/README.md
CHANGED
|
@@ -31,54 +31,255 @@ require 'gov_codes/afsc'
|
|
|
31
31
|
|
|
32
32
|
# Find an enlisted AFSC code
|
|
33
33
|
code = GovCodes::AFSC.find("1A1X2")
|
|
34
|
-
puts code.name # => "
|
|
34
|
+
puts code.name # => "Mobility Force Aviator"
|
|
35
35
|
puts code.career_field # => "1A"
|
|
36
36
|
puts code.career_field_subdivision # => "1A1"
|
|
37
37
|
puts code.skill_level # => "X"
|
|
38
38
|
puts code.specific_afsc # => "1A1X2"
|
|
39
39
|
puts code.shredout # => nil
|
|
40
|
+
puts code.effective_date # => #<Date: 2025-10-31>
|
|
40
41
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Look up the concrete code an HR system actually stores
|
|
43
|
+
# (the "7" is the airman's skill level: Craftsman)
|
|
44
|
+
code = GovCodes::AFSC.find("1A172Y")
|
|
45
|
+
code.specialty_name # => "Mobility Force Aviator"
|
|
46
|
+
code.skill_level_number # => 7
|
|
47
|
+
code.skill_level_name # => "Craftsman" (title comes from the directory)
|
|
48
|
+
code.specialty # => :"1A1X2"
|
|
49
|
+
|
|
50
|
+
# shredout_name resolves the shredout's meaning when that shredout
|
|
51
|
+
# exists in the data (nil otherwise)
|
|
52
|
+
code = GovCodes::AFSC.find("1A172A")
|
|
53
|
+
code.shredout_name # => "C-5 Flight Engineer"
|
|
54
|
+
|
|
55
|
+
# Enlisted lookups are versioned by DAFECD release (published each 30 Apr and
|
|
56
|
+
# 31 Oct). `find`/`search` default to today's date; pass `as_of:` (a Date or
|
|
57
|
+
# "YYYY-MM-DD" string) to resolve the release in effect on a different date. A
|
|
58
|
+
# release only takes effect on its own effective_date, even if it's the most
|
|
59
|
+
# recently added one (e.g. one you pre-load ahead of its official date).
|
|
60
|
+
code = GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01")
|
|
61
|
+
code.effective_date # => #<Date: 2025-10-31>
|
|
62
|
+
# A date before the earliest shipped release has no data and returns nil.
|
|
63
|
+
GovCodes::AFSC.find("1A172Y", as_of: "2000-01-01") # => nil
|
|
64
|
+
|
|
65
|
+
# Find an officer AFSC code. Officer lookups are versioned by DAFOCD release
|
|
66
|
+
# (like enlisted); `find`/`search` default to today's date and accept the
|
|
67
|
+
# same `as_of:`.
|
|
68
|
+
code = GovCodes::AFSC.find("11MX")
|
|
69
|
+
puts code.name # => "Mobility Pilot"
|
|
44
70
|
puts code.career_group # => "11"
|
|
45
71
|
puts code.functional_area # => "M"
|
|
46
|
-
puts code.qualification_level # => "
|
|
72
|
+
puts code.qualification_level # => "X"
|
|
47
73
|
puts code.shredout # => nil
|
|
74
|
+
puts code.effective_date # => #<Date: 2025-10-31>
|
|
75
|
+
|
|
76
|
+
# A concrete qualification level derives the X-form specialty and its title
|
|
77
|
+
code = GovCodes::AFSC.find("11B3")
|
|
78
|
+
code.specialty # => :"11BX"
|
|
79
|
+
code.specialty_name # => "Bomber Pilot"
|
|
80
|
+
code.qualification_level_number # => 3
|
|
81
|
+
code.qualification_level_name # => "Aircraft Commander" (title from the directory)
|
|
82
|
+
|
|
83
|
+
# Some officer specialties are keyed by a literal bare code (a single command/
|
|
84
|
+
# materiel-leader position, not a qualification ladder): 10C0, 62S0, 63G0, 63S0.
|
|
85
|
+
# These resolve ONLY via their literal code -- there is no X-form for them, so
|
|
86
|
+
# find("10CX") / find("62SX") return nil by design.
|
|
87
|
+
GovCodes::AFSC.find("10C0").name # => "Operations Commander"
|
|
88
|
+
GovCodes::AFSC.find("10CX") # => nil (bare-code specialty; no X-form)
|
|
89
|
+
|
|
90
|
+
# An officer code carries its acronym; a shredout acronym wins for a shredded code
|
|
91
|
+
GovCodes::AFSC.find("16F3").acronym # => "FAO"
|
|
92
|
+
GovCodes::AFSC.find("19ZXB").acronym # => "TACPO"
|
|
93
|
+
|
|
94
|
+
# Find a Reporting Identifier (RI) or Special Duty Identifier (SDI)
|
|
95
|
+
code = GovCodes::AFSC.find("8A400")
|
|
96
|
+
puts code.name # => "Talent Management Consultant"
|
|
97
|
+
puts code.career_field # => "8A"
|
|
98
|
+
puts code.identifier # => "400"
|
|
99
|
+
puts code.suffix # => nil
|
|
100
|
+
|
|
101
|
+
# An RI/SDI code carries its source-verified acronym, and reverse lookup is
|
|
102
|
+
# case-insensitive and as_of-aware, just like enlisted/officer AFSCs:
|
|
103
|
+
GovCodes::AFSC.find("9M200").acronym # => "IHS"
|
|
104
|
+
GovCodes::AFSC.find_by_acronym("IHS", as_of: "2025-11-01").name
|
|
105
|
+
# => "International Health Specialists (IHS)"
|
|
106
|
+
|
|
107
|
+
# Find a code with a shredout/suffix
|
|
108
|
+
code = GovCodes::AFSC.find("11BXA")
|
|
109
|
+
puts code.name # => "B-1"
|
|
110
|
+
puts code.specific_afsc # => "11BX"
|
|
111
|
+
puts code.shredout # => "A"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Searching for Codes
|
|
115
|
+
|
|
116
|
+
You can search for all codes matching a prefix:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# Search for all Special Warfare codes
|
|
120
|
+
results = GovCodes::AFSC.search("1Z")
|
|
121
|
+
results.each do |code|
|
|
122
|
+
puts "#{code.specific_afsc}: #{code.name}"
|
|
123
|
+
end
|
|
124
|
+
# Output:
|
|
125
|
+
# 1Z1X1: Pararescue
|
|
126
|
+
# 1Z2X1: Combat Control
|
|
127
|
+
# 1Z3X1: Tactical Air Control Party (TACP)
|
|
128
|
+
# 1Z4X1: Special Reconnaissance
|
|
129
|
+
|
|
130
|
+
# Search for Bomber Pilot shredouts
|
|
131
|
+
results = GovCodes::AFSC.search("11BX")
|
|
132
|
+
results.each do |code|
|
|
133
|
+
shredout = code.shredout ? code.shredout.to_s : ""
|
|
134
|
+
puts "#{code.specific_afsc}#{shredout}: #{code.name}"
|
|
135
|
+
end
|
|
136
|
+
# Output:
|
|
137
|
+
# 11BX: Bomber pilot
|
|
138
|
+
# 11BXA: B-1
|
|
139
|
+
# 11BXB: B-2
|
|
140
|
+
# 11BXC: B-52
|
|
141
|
+
# ...
|
|
142
|
+
|
|
143
|
+
# Search is case-insensitive
|
|
144
|
+
GovCodes::AFSC.search("1z1") # Same as search("1Z1")
|
|
48
145
|
```
|
|
49
146
|
|
|
50
147
|
### Extending with Custom AFSC Codes
|
|
51
148
|
|
|
52
|
-
|
|
149
|
+
Enlisted codes are stored as a specialty-keyed index per DAFECD release. You can
|
|
150
|
+
extend or override a release by dropping an index file for that release's
|
|
151
|
+
effective date onto your application's load path:
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
# In your application's lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml
|
|
155
|
+
:"9Z9X9":
|
|
156
|
+
:name: Custom Specialty
|
|
157
|
+
:career_field: :"9Z"
|
|
158
|
+
:skill_levels:
|
|
159
|
+
7:
|
|
160
|
+
:code: 9Z979
|
|
161
|
+
:title: Craftsman
|
|
162
|
+
:shredouts:
|
|
163
|
+
:A: Custom Shredout
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The gem merges your index over the shipped index for the matching release,
|
|
167
|
+
adding new specialties and overriding existing ones.
|
|
168
|
+
|
|
169
|
+
You can also add a whole new release (e.g. a newer directory the gem has not
|
|
170
|
+
shipped yet) by listing it in a `releases.yml` on your load path. Release lists
|
|
171
|
+
are unioned by effective date, so adding a release never hides the shipped ones;
|
|
172
|
+
a same-date entry from your file overrides the shipped manifest entry:
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
# In your application's lib/gov_codes/afsc/releases.yml
|
|
176
|
+
:dafecd:
|
|
177
|
+
- :effective_date: '2026-04-30'
|
|
178
|
+
:version_label: v3.6
|
|
179
|
+
:source: DAFECD-30-April-26.pdf
|
|
180
|
+
:name: Department of the Air Force Enlisted Classification Directory
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Pair it with a matching `releases/dafecd/2026-04-30/enlisted.yml` index, and
|
|
184
|
+
`find(code, as_of: "2026-04-30")` will resolve against it.
|
|
185
|
+
|
|
186
|
+
## Data source & provenance
|
|
187
|
+
|
|
188
|
+
AFSC data is extracted from the official **Department of the Air Force classification directories** — the DAFECD (enlisted) and DAFOCD (officer) — not third-party sources. Extraction is deterministic, and every code is verified to appear verbatim in the source directory: no predicted or hallucinated codes.
|
|
189
|
+
|
|
190
|
+
The data is **versioned by each directory's effective date** (the directories are republished roughly semi-annually, on 30 April and 31 October). Look a code up as it stands today, or as it stood for a given release:
|
|
53
191
|
|
|
54
192
|
```ruby
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
subcategories:
|
|
59
|
-
0X1:
|
|
60
|
-
name: Custom Subcategory
|
|
61
|
-
subcategories:
|
|
62
|
-
A:
|
|
63
|
-
name: Custom Shredout
|
|
193
|
+
GovCodes::AFSC.find("1A172Y") # the release in effect today
|
|
194
|
+
GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01") # the release in effect on that date
|
|
195
|
+
GovCodes::AFSC.find("1A172Y").effective_date # => the release the result came from
|
|
64
196
|
```
|
|
65
197
|
|
|
66
|
-
|
|
198
|
+
`as_of: nil` (the default) is today's date, not "whatever's newest" — a release only takes effect on its own effective_date. This only differs from "the latest shipped release" if a release dated in the future has been added to the load path (see "Extending with Custom AFSC Codes" above); until that date arrives, lookups keep resolving against the current release.
|
|
199
|
+
|
|
200
|
+
**Currently shipped:** enlisted AFSCs from the DAFECD and officer AFSCs from the DAFOCD, plus the reporting/special-duty identifiers (RI/SDI) from both directories (enlisted 5-char from the DAFECD, officer 4-char from the DAFOCD) — all effective 31 October 2025. SEIs, prefixes, and the full Space Force Specialty Codes (SFSC) are planned.
|
|
201
|
+
|
|
202
|
+
### Specialty acronyms
|
|
203
|
+
|
|
204
|
+
A resolved code carries an `acronym` field:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
GovCodes::AFSC.find("1C8X3").acronym # => "RAWS"
|
|
208
|
+
GovCodes::AFSC.find("1Z3X1").acronym # => "TACP"
|
|
209
|
+
GovCodes::AFSC.find("1A1X2").acronym # => nil (no acronym)
|
|
210
|
+
|
|
211
|
+
# Reverse lookup by acronym (case-insensitive, and `as_of`-aware — it searches
|
|
212
|
+
# the acronyms of the release in effect on that date, so overlays never leak
|
|
213
|
+
# across document dates):
|
|
214
|
+
GovCodes::AFSC.find_by_acronym("RAWS") # => the 1C8X3 Code
|
|
215
|
+
GovCodes::AFSC.find_by_acronym("raws") # => same (case-insensitive)
|
|
216
|
+
GovCodes::AFSC.find_by_acronym("PJ", as_of: "2025-11-01") # => resolves that release's overlay
|
|
217
|
+
GovCodes::AFSC.find_by_acronym("nope") # => nil
|
|
218
|
+
|
|
219
|
+
# Officer acronyms resolve too — including shredout acronyms, which return the
|
|
220
|
+
# concrete shredded code:
|
|
221
|
+
GovCodes::AFSC.find_by_acronym("TACPO") # => the 19ZXB Code (Special Warfare, TACP Officer)
|
|
222
|
+
GovCodes::AFSC.find_by_acronym("FAO") # => the 16FX Code (Foreign Area Officer)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Shipped acronyms are **source-verified**: they are captured only from a trailing parenthetical in the directory title (e.g. `Radar, Airfield & Weather Systems (RAWS)`) and the build gate rejects any acronym that does not appear verbatim in the source title. Five enlisted specialties ship an acronym this way (`1A8X1`, `1C8X3`, `1N1X1`, `1Z3X1`, `4B0X1`); nothing else is invented.
|
|
226
|
+
|
|
227
|
+
Everything else — colloquial acronyms not printed in the directory (e.g. `PJ` for Pararescue) — is **consumer-curated** via an optional overlay you drop on your load path. Overlays are flat `SPECIALTY => ACRONYM` (or `CODE => ACRONYM`) maps:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
# Enlisted, per DAFECD release date:
|
|
231
|
+
# lib/gov_codes/afsc/releases/dafecd/2025-10-31/acronyms.yml
|
|
232
|
+
:"1Z1X1": PJ # augments the Pararescue entry; name/skill_levels/shredouts untouched
|
|
233
|
+
:"1Z3X1": JTAC # overrides the shipped TACP acronym
|
|
234
|
+
|
|
235
|
+
# Officer, per DAFOCD release date:
|
|
236
|
+
# lib/gov_codes/afsc/releases/dafocd/2025-10-31/acronyms.yml
|
|
237
|
+
:"11MX": MOBPLT # augments the Mobility Pilot entry (ships no acronym)
|
|
238
|
+
:"16FX": FAREA # overrides the shipped FAO acronym
|
|
239
|
+
|
|
240
|
+
# RI/SDI acronyms live in the SAME per-release acronyms.yml, keyed by RI code:
|
|
241
|
+
# 5-char enlisted RI in the DAFECD file, 4-char officer RI in the DAFOCD file.
|
|
242
|
+
:"8A400": TMC # enlisted RI/SDI -> dafecd/<date>/acronyms.yml
|
|
243
|
+
:"90G0": GENOFF # officer RI/SDI -> dafocd/<date>/acronyms.yml
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Key points:
|
|
247
|
+
|
|
248
|
+
- **Enlisted, officer, and RI/SDI overlays are all per-release-date**, scoped by the directory's effective date, so `find(code, as_of: "2025-11-01")` applies that release's overlay — and each publication resolves independently, so an officer overlay never affects an enlisted lookup or vice versa. RI/SDI reuses the same per-release, per-publication `acronyms.yml` tier (enlisted RI from the DAFECD file, officer RI from the DAFOCD file), so an RI overlay never crosses into an enlisted/officer lookup either.
|
|
249
|
+
- The overlay is a **separate tier** — it only sets `:acronym` on entries that already exist and never replaces an entry, so `name`, `qual_levels`/`skill_levels`, and `shredouts` stay intact.
|
|
250
|
+
- **Precedence:** the consumer overlay wins over the shipped, source-verified acronym. For officer codes a documented shredout acronym still wins for a shredded code. The gem ships no overlay file, so absent one the shipped index is used as-is.
|
|
67
251
|
|
|
68
252
|
## Development
|
|
69
253
|
|
|
70
254
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
71
255
|
|
|
72
|
-
|
|
256
|
+
### Regenerating the data
|
|
73
257
|
|
|
74
|
-
|
|
258
|
+
Enlisted (DAFECD) and officer (DAFOCD) indexes are regenerated deterministically
|
|
259
|
+
from the official classification-directory PDFs by `bin/extract_afsc_from_pdf.rb`
|
|
260
|
+
(offline dev tooling; never loaded by the gem runtime). It writes the versioned
|
|
261
|
+
release artifact under `lib/gov_codes/afsc/releases/<publication>/<date>/`, updates
|
|
262
|
+
the manifest, and fails loudly before writing if any code, title, acronym, or
|
|
263
|
+
shredout override is not grounded in the source:
|
|
75
264
|
|
|
76
|
-
|
|
265
|
+
```bash
|
|
266
|
+
mise exec -- ruby bin/extract_afsc_from_pdf.rb "DAFECD -31 October 25 v3.5 FINAL.pdf"
|
|
267
|
+
mise exec -- ruby bin/extract_afsc_from_pdf.rb "DAFOCD 31 Oct 25 v3.pdf"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The retired `bin/extract_afsc_from_wikipedia.rb` (which generated the old flat
|
|
271
|
+
enlisted/officer YAML) has been removed in favor of the PDF extractor. RI/SDI
|
|
272
|
+
data is now sourced from the same DAFECD (enlisted, 5-char) and DAFOCD (officer,
|
|
273
|
+
4-char) directories, under the same anti-hallucination gate — every RI code,
|
|
274
|
+
title, and acronym must appear verbatim in the source — and ships as versioned
|
|
275
|
+
artifacts (`releases/<publication>/<date>/ri.yml`) alongside the enlisted and
|
|
276
|
+
officer indexes.
|
|
77
277
|
|
|
78
|
-
To
|
|
278
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
279
|
+
|
|
280
|
+
This project is managed with [Reissue](https://github.com/SOFware/reissue).
|
|
79
281
|
|
|
80
|
-
|
|
81
|
-
2. `bundle exec rake release`
|
|
282
|
+
Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
|
|
82
283
|
|
|
83
284
|
## Contributing
|
|
84
285
|
|
data/Rakefile
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "minitest/test_task"
|
|
5
5
|
|
|
6
|
-
Minitest::TestTask.create
|
|
6
|
+
Minitest::TestTask.create do |t|
|
|
7
|
+
# Load test_helper before minitest/autorun to ensure SimpleCov's at_exit
|
|
8
|
+
# handler runs AFTER tests complete (at_exit runs in LIFO order)
|
|
9
|
+
t.test_prelude = 'require "test_helper"'
|
|
10
|
+
end
|
|
7
11
|
|
|
8
12
|
require "standard/rake"
|
|
9
13
|
|
|
@@ -13,4 +17,6 @@ require "reissue/gem"
|
|
|
13
17
|
|
|
14
18
|
Reissue::Task.create :reissue do |task|
|
|
15
19
|
task.version_file = "lib/gov_codes/version.rb"
|
|
20
|
+
task.fragment = :git
|
|
21
|
+
task.push_finalize = :branch
|
|
16
22
|
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a47544403935737f6dc23d24d03620744582275e6f585ca3dd7610b01efde9afbafe3b54949b5fc16c67d92b56a5175ec4109237d7eda96df86821eab3c2c8ef
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1a8f6a45b4e04a3a4502853a10d91f9684a7adeebad467b8b97f1904161bf4629a6ebf86ad177d73be605a484a521d9f4f36e289b301500d841bdc9887c31f6a
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
require "strscan"
|
|
2
|
-
|
|
3
|
-
require_relative "../data_loader"
|
|
4
|
-
|
|
5
|
-
puts "Loading enlisted.rb"
|
|
2
|
+
require_relative "releases"
|
|
6
3
|
|
|
7
4
|
module GovCodes
|
|
8
5
|
module AFSC
|
|
9
6
|
module Enlisted
|
|
7
|
+
SKILL_LEVELS = {
|
|
8
|
+
1 => "Helper",
|
|
9
|
+
3 => "Apprentice",
|
|
10
|
+
5 => "Journeyman",
|
|
11
|
+
7 => "Craftsman",
|
|
12
|
+
9 => "Senior Enlisted Leader"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
10
15
|
class Parser
|
|
11
16
|
def initialize(code)
|
|
12
17
|
@code = code
|
|
@@ -20,6 +25,10 @@ module GovCodes
|
|
|
20
25
|
career_field: nil,
|
|
21
26
|
career_field_subdivision: nil,
|
|
22
27
|
skill_level: nil,
|
|
28
|
+
skill_level_number: nil,
|
|
29
|
+
skill_level_name: nil,
|
|
30
|
+
specialty: nil,
|
|
31
|
+
specialty_name: nil,
|
|
23
32
|
specific_afsc: nil,
|
|
24
33
|
subcategory: nil,
|
|
25
34
|
shredout: nil
|
|
@@ -38,22 +47,34 @@ module GovCodes
|
|
|
38
47
|
return result unless career_field_letter
|
|
39
48
|
result[:career_field] = :"#{career_group}#{career_field_letter}"
|
|
40
49
|
|
|
41
|
-
# Scan for subdivision digit
|
|
50
|
+
# Scan for subdivision digit
|
|
42
51
|
subdivision_digit = scanner.scan(/\d/)
|
|
43
52
|
return result unless subdivision_digit
|
|
44
53
|
result[:career_field_subdivision] = :"#{result[:career_field]}#{subdivision_digit}"
|
|
45
54
|
|
|
46
|
-
# Scan for skill level
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
# Scan for skill level: an X placeholder OR a concrete digit.
|
|
56
|
+
# NOTE: any digit is accepted here for now; validating that it is a
|
|
57
|
+
# real skill level (1/3/5/7/9) is deferred to Phase C.
|
|
58
|
+
skill_char = scanner.scan(/[A-Z0-9]/)
|
|
59
|
+
return result unless skill_char
|
|
60
|
+
result[:skill_level] = skill_char.to_sym
|
|
50
61
|
|
|
51
|
-
#
|
|
62
|
+
# Concrete skill level -> numeric level + standard title
|
|
63
|
+
if skill_char.match?(/\d/)
|
|
64
|
+
result[:skill_level_number] = Integer(skill_char)
|
|
65
|
+
result[:skill_level_name] = SKILL_LEVELS[result[:skill_level_number]]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Scan for the specific AFSC digit
|
|
52
69
|
specific_digit = scanner.scan(/\d/)
|
|
53
70
|
return result unless specific_digit
|
|
54
|
-
result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{result[:skill_level]}#{specific_digit}"
|
|
55
71
|
|
|
56
|
-
|
|
72
|
+
# Normalize to the X-form specialty key regardless of concrete skill level
|
|
73
|
+
result[:subcategory] = :"#{subdivision_digit}X#{specific_digit}"
|
|
74
|
+
result[:specialty] = :"#{result[:career_field_subdivision]}X#{specific_digit}"
|
|
75
|
+
|
|
76
|
+
# specific_afsc preserves the code exactly as entered (concrete or generic)
|
|
77
|
+
result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_char}#{specific_digit}"
|
|
57
78
|
|
|
58
79
|
# Scan for shredout (optional)
|
|
59
80
|
result[:shredout] = scanner.scan(/[A-Z]/)&.to_sym
|
|
@@ -65,8 +86,8 @@ module GovCodes
|
|
|
65
86
|
end
|
|
66
87
|
end
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
CODES = {}
|
|
90
|
+
private_constant :CODES
|
|
70
91
|
|
|
71
92
|
Code = Data.define(
|
|
72
93
|
:prefix,
|
|
@@ -74,30 +95,119 @@ module GovCodes
|
|
|
74
95
|
:career_field,
|
|
75
96
|
:career_field_subdivision,
|
|
76
97
|
:skill_level,
|
|
98
|
+
:skill_level_number,
|
|
99
|
+
:skill_level_name,
|
|
100
|
+
:specialty,
|
|
101
|
+
:specialty_name,
|
|
77
102
|
:specific_afsc,
|
|
78
103
|
:subcategory,
|
|
79
104
|
:shredout,
|
|
80
|
-
:
|
|
105
|
+
:shredout_name,
|
|
106
|
+
:name,
|
|
107
|
+
:acronym,
|
|
108
|
+
:effective_date
|
|
81
109
|
)
|
|
82
110
|
|
|
83
|
-
|
|
111
|
+
# Resolve an enlisted AFSC against the DAFECD release in effect on +as_of+
|
|
112
|
+
# (default: today). Returns nil when the code does not parse, when the
|
|
113
|
+
# specialty is absent from the resolved release, or when +as_of+ precedes
|
|
114
|
+
# the earliest shipped release (or no release has taken effect yet).
|
|
115
|
+
def self.find(code, as_of: nil)
|
|
84
116
|
code = code.to_s
|
|
85
|
-
|
|
117
|
+
# Key the memo on the RESOLVED release date so equivalent as_of values
|
|
118
|
+
# (nil, the Date, its string form, any date in the same release window)
|
|
119
|
+
# share one slot instead of growing unbounded for time-series callers.
|
|
120
|
+
effective_date = Releases.effective_date_for(as_of: as_of)
|
|
121
|
+
CODES[[code, effective_date]] ||= begin
|
|
86
122
|
parser = Parser.new(code)
|
|
87
123
|
result = parser.parse
|
|
88
124
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
# Return nil if parsing failed or required fields are missing
|
|
126
|
+
return nil if result.reject { |_, v| v.nil? }.empty? ||
|
|
127
|
+
result[:career_group].nil? ||
|
|
128
|
+
result[:career_field].nil? ||
|
|
129
|
+
result[:career_field_subdivision].nil? ||
|
|
130
|
+
result[:skill_level].nil? ||
|
|
131
|
+
result[:specific_afsc].nil? ||
|
|
132
|
+
result[:subcategory].nil?
|
|
133
|
+
|
|
134
|
+
# Additional validation: check for invalid characters or too long codes
|
|
135
|
+
return nil if code.length > 7 ||
|
|
136
|
+
code.match?(/[^A-Z0-9]/)
|
|
137
|
+
|
|
138
|
+
# Resolve the specialty entry from the versioned, specialty-keyed index
|
|
139
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
140
|
+
entry = index[result[:specialty]]
|
|
141
|
+
return nil unless entry
|
|
142
|
+
|
|
143
|
+
specialty_name = entry[:name]
|
|
144
|
+
|
|
145
|
+
# Skill-level title: prefer the directory's title for this specialty,
|
|
146
|
+
# falling back to the universal enlisted skill-level map.
|
|
147
|
+
if (number = result[:skill_level_number])
|
|
148
|
+
result[:skill_level_name] = entry.dig(:skill_levels, number, :title) ||
|
|
149
|
+
SKILL_LEVELS[number]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Shredout meaning, only when the directory documents this shredout.
|
|
153
|
+
shredout_name = result[:shredout] && entry.dig(:shredouts, result[:shredout])
|
|
154
|
+
|
|
155
|
+
name = shredout_name || specialty_name
|
|
156
|
+
return nil if name.nil?
|
|
157
|
+
|
|
158
|
+
result[:shredout_name] = shredout_name
|
|
159
|
+
result[:specialty_name] = specialty_name
|
|
95
160
|
result[:name] = name
|
|
161
|
+
# Specialty acronym from the resolved index entry (nil when absent).
|
|
162
|
+
result[:acronym] = entry[:acronym]
|
|
163
|
+
result[:effective_date] = effective_date
|
|
96
164
|
|
|
97
|
-
# Create a new Code object with the result
|
|
98
165
|
Code.new(**result)
|
|
99
166
|
end
|
|
100
167
|
end
|
|
168
|
+
|
|
169
|
+
# Resolve the enlisted specialty whose acronym matches +acronym+
|
|
170
|
+
# (case-insensitive) in the release in effect on +as_of+. Returns the
|
|
171
|
+
# generic (X-form) Code for that specialty, or nil when no specialty in the
|
|
172
|
+
# resolved release carries the acronym. Acronyms come from the shipped,
|
|
173
|
+
# source-verified data and any consumer overlay for that release, so the
|
|
174
|
+
# match is scoped to that release (no leakage across document dates).
|
|
175
|
+
def self.find_by_acronym(acronym, as_of: nil)
|
|
176
|
+
acronym = acronym.to_s.upcase
|
|
177
|
+
return nil if acronym.empty?
|
|
178
|
+
|
|
179
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
180
|
+
match = index.find { |_specialty, entry| entry[:acronym].to_s.upcase == acronym }
|
|
181
|
+
return nil unless match
|
|
182
|
+
|
|
183
|
+
find(match.first.to_s, as_of: as_of)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Clears the memoized lookups and resets the versioned release loader.
|
|
187
|
+
# The +lookup+ keyword is accepted for interface parity with Officer/RI;
|
|
188
|
+
# the versioned index is resolved from the load path at lookup time.
|
|
189
|
+
def self.reset_data(lookup: $LOAD_PATH)
|
|
190
|
+
Releases.reset!
|
|
191
|
+
CODES.clear
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.search(prefix, as_of: nil)
|
|
195
|
+
prefix = prefix.to_s.upcase
|
|
196
|
+
index = Releases.enlisted_index(as_of: as_of)
|
|
197
|
+
|
|
198
|
+
codes = []
|
|
199
|
+
index.each do |specialty, entry|
|
|
200
|
+
specialty_code = specialty.to_s
|
|
201
|
+
codes << specialty_code
|
|
202
|
+
(entry[:shredouts] || {}).each_key do |shredout|
|
|
203
|
+
codes << "#{specialty_code}#{shredout}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
codes.select { |code| code.start_with?(prefix) }
|
|
208
|
+
.map { |code| find(code, as_of: as_of) }
|
|
209
|
+
.compact
|
|
210
|
+
end
|
|
101
211
|
end
|
|
102
212
|
end
|
|
103
213
|
end
|