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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bde662fd124b6754ac6c830f95e58cbcf63d8cce2168f04a410b5fe0df1c80d9
4
- data.tar.gz: 77894785fb4edf1bcb2fe575fe6ed55932a7f0eadbd056988bd666742e7c2b06
3
+ metadata.gz: 11433a91ce2be5af9141fa58c9110a9d0f383b9769550f2fc95843b27d32da6b
4
+ data.tar.gz: 49d09edca49ae863444781be3da6eff64cffa2f499b8442d002736e7dc8624be
5
5
  SHA512:
6
- metadata.gz: 0c8d2bcb57eb86164c8484b9db9b2c00bf9853a0fa0ef33178a8f02267d4558a88d3ca1834458de46c120a0bf68a0f3f566f65c65c865dda0c51c4ab7352ca05
7
- data.tar.gz: 335e26092052f94f0b949cf28cc6c1d3d4194621f641dfa78b9098b05b71ddc6fb71c8a3e1c911d029064ff891b4114ea719a2faee4ab7d3c3d99a57959ed7ca
6
+ metadata.gz: 8dc82c96d74319e22b9d24d8ecc2f9c827eec171921f28158118fde4dc3586dcaa1ca5c06bd0827b954e5e659e5471eed404e7b704ad1d60f8231d9df6857e17
7
+ data.tar.gz: 4091a9fe8b0e0922817a01aeaaf0c48faf88339fb80431b7556975fa41c0e0c4445cc422dbcae782b91a1a8a1a4414d8ca3de8894b33af5a0742050bb73e4e3a
data/.simplecov CHANGED
@@ -1,3 +1,6 @@
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
 
@@ -12,6 +15,15 @@ SimpleCov.start do
12
15
  track_files "lib/**/*.rb"
13
16
 
14
17
  # Group files by module
15
-
16
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
17
29
  end
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.3
1
+ ruby 4.0.5
data/CHANGELOG.md CHANGED
@@ -5,16 +5,40 @@ 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.1] - 2025-11-17
8
+ ## [0.1.2] - 2026-07-16
9
+
10
+ ### Added
11
+
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)
9
24
 
10
25
  ### Changed
11
26
 
12
- - Test against Ruby 3.3.8 and 3.4.3
13
- - Officer YAML structure to match Wikipedia (11BX, 11MX, etc.) (4ca6aef)
14
- - Officer parser to accept letter qualification levels (X, Y, Z) (4ca6aef)
15
- - Officer lookup to include qualification level in key (4ca6aef)
16
- - Enlisted/Officer lookups to handle String leaf values (4ca6aef)
17
- - Use git trailers to track changelog changes. (c83929a)
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
18
42
 
19
43
  ### Added
20
44
 
@@ -25,16 +49,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
25
49
  - Reporting identifiers with GovCodes::AFSC::RI (6102656)
26
50
  - AFSC.search to return an array of matching codes for the given prefix. (5c5bdf1)
27
51
 
28
- ### Removed
29
-
30
- - 824 hallucinated codes not found in Wikipedia (4ca6aef)
31
-
32
- ### Fixed
33
-
34
- - Tests updated to use real Wikipedia codes (4ca6aef)
35
-
36
- ## [0.1.1] - 2025-11-17
37
-
38
52
  ### Changed
39
53
 
40
54
  - Test against Ruby 3.3.8 and 3.4.3
@@ -44,15 +58,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
44
58
  - Enlisted/Officer lookups to handle String leaf values (4ca6aef)
45
59
  - Use git trailers to track changelog changes. (c83929a)
46
60
 
47
- ### Added
48
-
49
- - AFSC Officer support (bb96aaa)
50
- - Data extractor script for Wikipedia (c8ec55b)
51
- - 1Z Special Warfare career field (Pararescue, Combat Control, TACP) (4ca6aef)
52
- - Nokogiri gem for HTML parsing (4ca6aef)
53
- - Reporting identifiers with GovCodes::AFSC::RI (6102656)
54
- - AFSC.search to return an array of matching codes for the given prefix. (5c5bdf1)
55
-
56
61
  ### Removed
57
62
 
58
63
  - 824 hallucinated codes not found in Wikipedia (4ca6aef)
data/README.md CHANGED
@@ -31,28 +31,79 @@ require 'gov_codes/afsc'
31
31
 
32
32
  # Find an enlisted AFSC code
33
33
  code = GovCodes::AFSC.find("1A1X2")
34
- puts code.name # => "Mobility force aviator"
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
-
41
- # Find an officer AFSC code
40
+ puts code.effective_date # => #<Date: 2025-10-31>
41
+
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:`.
42
68
  code = GovCodes::AFSC.find("11MX")
43
- puts code.name # => "Mobility pilot"
69
+ puts code.name # => "Mobility Pilot"
44
70
  puts code.career_group # => "11"
45
71
  puts code.functional_area # => "M"
46
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"
48
93
 
49
94
  # Find a Reporting Identifier (RI) or Special Duty Identifier (SDI)
50
95
  code = GovCodes::AFSC.find("8A400")
51
- puts code.name # => "Talent management consultant"
96
+ puts code.name # => "Talent Management Consultant"
52
97
  puts code.career_field # => "8A"
53
98
  puts code.identifier # => "400"
54
99
  puts code.suffix # => nil
55
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
+
56
107
  # Find a code with a shredout/suffix
57
108
  code = GovCodes::AFSC.find("11BXA")
58
109
  puts code.name # => "B-1"
@@ -72,9 +123,9 @@ results.each do |code|
72
123
  end
73
124
  # Output:
74
125
  # 1Z1X1: Pararescue
75
- # 1Z2X1: Combat control
76
- # 1Z3X1: Tactical air control party (TACP)
77
- # 1Z4X1: Special reconnaissance
126
+ # 1Z2X1: Combat Control
127
+ # 1Z3X1: Tactical Air Control Party (TACP)
128
+ # 1Z4X1: Special Reconnaissance
78
129
 
79
130
  # Search for Bomber Pilot shredouts
80
131
  results = GovCodes::AFSC.search("11BX")
@@ -95,36 +146,140 @@ GovCodes::AFSC.search("1z1") # Same as search("1Z1")
95
146
 
96
147
  ### Extending with Custom AFSC Codes
97
148
 
98
- You can extend the default AFSC codes with your own custom codes by placing a YAML file in your application's load path:
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:
191
+
192
+ ```ruby
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
196
+ ```
197
+
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:
99
205
 
100
206
  ```ruby
101
- # In your application's lib/gov_codes/afsc/enlisted.yml
102
- 9Z:
103
- name: Custom AFSC
104
- subcategories:
105
- 0X1:
106
- name: Custom Subcategory
107
- subcategories:
108
- A:
109
- name: Custom Shredout
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)
110
223
  ```
111
224
 
112
- The gem will automatically merge your custom codes with the default codes, overriding any existing codes.
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.
113
251
 
114
252
  ## Development
115
253
 
116
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.
117
255
 
118
- To install this gem onto your local machine, run `bundle exec rake install`.
256
+ ### Regenerating the data
119
257
 
120
- This project is managed with [Reissue](https://github.com/SOFware/reissue).
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:
264
+
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
+ ```
121
269
 
122
- To release a new version, make your changes and be sure to update the CHANGELOG.md.
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.
123
277
 
124
- To release a new version:
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).
125
281
 
126
- 1. `bundle exec rake build:checksum`
127
- 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.
128
283
 
129
284
  ## Contributing
130
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,5 +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"
16
- task.fragment = :git # Enable git trailer extraction for changelog
20
+ task.fragment = :git
21
+ task.push_finalize = :branch
17
22
  end
@@ -0,0 +1 @@
1
+ 1a8f6a45b4e04a3a4502853a10d91f9684a7adeebad467b8b97f1904161bf4629a6ebf86ad177d73be605a484a521d9f4f36e289b301500d841bdc9887c31f6a
@@ -1,10 +1,17 @@
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
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
+
8
15
  class Parser
9
16
  def initialize(code)
10
17
  @code = code
@@ -18,6 +25,10 @@ module GovCodes
18
25
  career_field: nil,
19
26
  career_field_subdivision: nil,
20
27
  skill_level: nil,
28
+ skill_level_number: nil,
29
+ skill_level_name: nil,
30
+ specialty: nil,
31
+ specialty_name: nil,
21
32
  specific_afsc: nil,
22
33
  subcategory: nil,
23
34
  shredout: nil
@@ -41,20 +52,29 @@ module GovCodes
41
52
  return result unless subdivision_digit
42
53
  result[:career_field_subdivision] = :"#{result[:career_field]}#{subdivision_digit}"
43
54
 
44
- # Scan for skill level letter (usually X)
45
- skill_level_letter = scanner.scan(/[A-Z]/)
46
- return result unless skill_level_letter
47
- result[:skill_level] = skill_level_letter.to_sym
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
61
+
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
48
67
 
49
- # Scan for skill level digit
50
- skill_level_digit = scanner.scan(/\d/)
51
- return result unless skill_level_digit
68
+ # Scan for the specific AFSC digit
69
+ specific_digit = scanner.scan(/\d/)
70
+ return result unless specific_digit
52
71
 
53
- # Subcategory is subdivision digit + letter + digit (e.g. '1X2')
54
- result[:subcategory] = :"#{subdivision_digit}#{skill_level_letter}#{skill_level_digit}"
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}"
55
75
 
56
- # Build specific AFSC
57
- result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_level_letter}#{skill_level_digit}"
76
+ # specific_afsc preserves the code exactly as entered (concrete or generic)
77
+ result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_char}#{specific_digit}"
58
78
 
59
79
  # Scan for shredout (optional)
60
80
  result[:shredout] = scanner.scan(/[A-Z]/)&.to_sym
@@ -66,9 +86,8 @@ module GovCodes
66
86
  end
67
87
  end
68
88
 
69
- extend GovCodes::DataLoader
70
-
71
- DATA = data
89
+ CODES = {}
90
+ private_constant :CODES
72
91
 
73
92
  Code = Data.define(
74
93
  :prefix,
@@ -76,15 +95,30 @@ module GovCodes
76
95
  :career_field,
77
96
  :career_field_subdivision,
78
97
  :skill_level,
98
+ :skill_level_number,
99
+ :skill_level_name,
100
+ :specialty,
101
+ :specialty_name,
79
102
  :specific_afsc,
80
103
  :subcategory,
81
104
  :shredout,
82
- :name
105
+ :shredout_name,
106
+ :name,
107
+ :acronym,
108
+ :effective_date
83
109
  )
84
110
 
85
- def self.find(code)
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)
86
116
  code = code.to_s
87
- CODES[code] ||= begin
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
88
122
  parser = Parser.new(code)
89
123
  result = parser.parse
90
124
 
@@ -101,81 +135,78 @@ module GovCodes
101
135
  return nil if code.length > 7 ||
102
136
  code.match?(/[^A-Z0-9]/)
103
137
 
104
- # Find the name by recursively searching the codes hash
105
- name = find_name_recursive(result)
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]
106
144
 
107
- # Return nil if name is 'Unknown'
108
- return nil if name == "Unknown"
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])
109
154
 
110
- # Add the name to the result
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
111
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
112
164
 
113
- # Create a new Code object with the result
114
165
  Code.new(**result)
115
166
  end
116
167
  end
117
168
 
118
- def self.find_name_recursive(result)
119
- data = DATA
120
-
121
- # Career field (e.g., "9Z")
122
- cf = result[:career_field]&.to_sym
123
- return "Unknown" unless cf && data[cf]
124
- name = data[cf][:name]
125
- data = data[cf][:subcategories]
126
-
127
- # Subcategory (e.g., "0X1" from "9Z0X1")
128
- if data && result[:subcategory]
129
- sub = result[:subcategory].to_sym
130
- lookup_value = data[sub]
131
- if lookup_value
132
- if lookup_value.is_a?(Hash)
133
- name = lookup_value[:name] || name
134
- data = lookup_value[:subcategories]
135
- else
136
- # String value (leaf node)
137
- name = lookup_value
138
- data = nil
139
- end
140
- end
141
- end
142
-
143
- # Shredout (optional, e.g., :A)
144
- if data && result[:shredout]
145
- lookup_value = data[result[:shredout]]
146
- if lookup_value
147
- name = lookup_value.is_a?(Hash) ? (lookup_value[:name] || name) : (lookup_value || name)
148
- end
149
- end
150
-
151
- name || "Unknown"
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)
152
184
  end
153
185
 
154
- def self.search(prefix)
155
- results = []
156
- prefix = prefix.to_s.upcase
157
- collect_codes_recursive(DATA, "", prefix, results)
158
- results.map { |code| find(code) }.compact
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
159
192
  end
160
193
 
161
- def self.collect_codes_recursive(data, current_code, prefix, results)
162
- return unless data.is_a?(Hash)
163
-
164
- data.each do |key, value|
165
- code = "#{current_code}#{key}"
166
-
167
- if value.is_a?(Hash) && value[:name]
168
- # This is a node with a name and possibly subcategories
169
- results << code if code.start_with?(prefix)
170
- collect_codes_recursive(value[:subcategories], code, prefix, results) if value[:subcategories]
171
- elsif value.is_a?(String)
172
- # This is a leaf node (simple string value)
173
- results << code if code.start_with?(prefix)
174
- elsif value.is_a?(Hash)
175
- # Nested subcategories without a name at this level
176
- collect_codes_recursive(value, current_code, prefix, results)
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}"
177
204
  end
178
205
  end
206
+
207
+ codes.select { |code| code.start_with?(prefix) }
208
+ .map { |code| find(code, as_of: as_of) }
209
+ .compact
179
210
  end
180
211
  end
181
212
  end