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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96c91603e5956e6918476045972a183754d150c8bfef8a8f7d33de5159ca4c60
4
- data.tar.gz: 72ade76ae58f72cfcc07ff2f301bd3a7067fc5e1fb836ca91438b767ff1198ae
3
+ metadata.gz: 11433a91ce2be5af9141fa58c9110a9d0f383b9769550f2fc95843b27d32da6b
4
+ data.tar.gz: 49d09edca49ae863444781be3da6eff64cffa2f499b8442d002736e7dc8624be
5
5
  SHA512:
6
- metadata.gz: cbf18e6e8e9099d19c023e12ed688374335213dc04ec9c95217a2b4f3df085fa29be735d4de828ddea254306357ff7463d4e2346e344ccde9281ac0c593ca258
7
- data.tar.gz: 82a5a80e5d3880e65711f7ee09c0d9b3c50b866faf879360997bfec5b98830bd2fe4c49f467e3545800cd80f6e9acd6f3cc41c7735cbdf06c592ff7fdc0ee172
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/", "lib/gov_codes/version.rb"
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 3.4.3
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.0] - 2025-05-02
8
+ ## [0.1.2] - 2026-07-16
9
9
 
10
10
  ### Added
11
11
 
12
- - AFSC codes
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 # => "Aircrew operations"
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
- # Find an officer AFSC code
42
- code = GovCodes::AFSC.find("11M4")
43
- puts code.name # => "Pilot"
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 # => "4"
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
- 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:
53
191
 
54
192
  ```ruby
55
- # In your application's lib/gov_codes/afsc/enlisted.yml
56
- 9Z:
57
- name: Custom AFSC
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
- The gem will automatically merge your custom codes with the default codes, overriding any existing codes.
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
- To install this gem onto your local machine, run `bundle exec rake install`.
256
+ ### Regenerating the data
73
257
 
74
- 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:
75
264
 
76
- To release a new version, make your changes and be sure to update the CHANGELOG.md.
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 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).
79
281
 
80
- 1. `bundle exec rake build:checksum`
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
- require "yaml"
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 and combine with career field
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
- skill_level = scanner.scan(/[\dX]/)
48
- return result unless skill_level
49
- result[:skill_level] = skill_level.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
50
61
 
51
- # Scan for specific AFSC digit and combine with previous components
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
- result[:subcategory] = result[:specific_afsc][2..4].to_sym
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
- extend GovCodes::DataLoader
69
- DATA = data
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
- :name
105
+ :shredout_name,
106
+ :name,
107
+ :acronym,
108
+ :effective_date
81
109
  )
82
110
 
83
- 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)
84
116
  code = code.to_s
85
- 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
86
122
  parser = Parser.new(code)
87
123
  result = parser.parse
88
124
 
89
- return nil if result.reject { |_, v| v.nil? }.empty?
90
-
91
- # Find the name by recursively searching the codes hash
92
- name = find_name_recursive(result)
93
-
94
- # Add the name to the result
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