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
@@ -0,0 +1,193 @@
1
+ # DAFOCD Reporting Identifiers (RI) + Special Duty Identifiers (SDI)
2
+ # Source: DAFOCD 31 Oct 25 v3.pdf
3
+ # Directory: Department of the Air Force Officer Classification Directory
4
+ # Effective date: 2025-10-31 (v3)
5
+ # Generated deterministically by bin/extract_afsc_from_pdf.rb.
6
+ # Do not edit by hand; re-run the extractor against the source PDF.
7
+ # Titles are de-glued via human-verified overrides (spacing/case only, gated
8
+ # against the verbatim source); a title without an override is kept verbatim.
9
+ ---
10
+ :80C0:
11
+ :name: Commander, Cadet Squadron, USAF Academy
12
+ :changed_date: '2022-10-31'
13
+ :81C0:
14
+ :name: Instructor, Officer Training School
15
+ :changed_date: '2019-04-30'
16
+ :81D0:
17
+ :name: Air Force Reserve Officer Training Corps Detachment Commander and Professor
18
+ of Aerospace Studies
19
+ :changed_date: '2020-04-30'
20
+ :81L0:
21
+ :name: Education and Training Leader
22
+ :changed_date: '2020-04-30'
23
+ :81T0:
24
+ :name: Instructor
25
+ :82A0:
26
+ :name: Academic Program Manager
27
+ :83R0:
28
+ :name: Recruiting Service
29
+ :84H0:
30
+ :name: Historian
31
+ :changed_date: '2024-10-31'
32
+ :85G0:
33
+ :name: Honor Guard
34
+ :changed_date: '2025-10-31'
35
+ :86M0:
36
+ :name: Operations Management
37
+ :86P0:
38
+ :name: Command and Control
39
+ :87G0:
40
+ :name: Wing Inspector General
41
+ :changed_date: '2018-04-30'
42
+ :87I0:
43
+ :name: Director, Wing Inspections
44
+ :changed_date: '2012-10-31'
45
+ :87Q0:
46
+ :name: Director, Complaints Resolution
47
+ :changed_date: '2012-10-31'
48
+ :88A0:
49
+ :name: Aide-de-camp
50
+ :88B0:
51
+ :name: Protocol Officer
52
+ :changed_date: '2019-10-31'
53
+ :88C0:
54
+ :name: Sexual Assault Response Coordinator (SARC)
55
+ :acronym: SARC
56
+ :changed_date: '2020-04-30'
57
+ :88I0:
58
+ :name: Innovation Officer
59
+ :changed_date: '2022-10-31'
60
+ :88M0:
61
+ :name: United States Air Force Medical Pinnacle Position
62
+ :changed_date: '2024-04-30'
63
+ :88X0:
64
+ :name: Operational STEM Officer
65
+ :changed_date: '2024-10-31'
66
+ :88Y0:
67
+ :name: Pathfinder Officer
68
+ :changed_date: '2023-10-31'
69
+ :89A0:
70
+ :name: Air Advisor (Basic)
71
+ :changed_date: '2022-10-31'
72
+ :89B0:
73
+ :name: Air Advisor (Basic) Team Leader
74
+ :changed_date: '2022-10-31'
75
+ :89C0:
76
+ :name: Air Advisor (Basic) Mission Commander
77
+ :changed_date: '2022-10-31'
78
+ :89D0:
79
+ :name: Air Advisor (Advanced)
80
+ :changed_date: '2022-10-31'
81
+ :89E0:
82
+ :name: Air Advisor (Advanced) Team Leader
83
+ :changed_date: '2022-10-31'
84
+ :89F0:
85
+ :name: Air Advisor (Advanced) Mission Commander
86
+ :changed_date: '2022-10-31'
87
+ :89G0:
88
+ :name: Combat Aviation Advisor
89
+ :changed_date: '2025-04-30'
90
+ :89H0:
91
+ :name: Combat Aviation Advisor Team Leader
92
+ :changed_date: '2025-04-30'
93
+ :89I0:
94
+ :name: Combat Aviation Advisor Mission Commander
95
+ :changed_date: '2025-04-30'
96
+ :89W0:
97
+ :name: Weapons of Mass Destruction Civil Support Team (WMD-CST)
98
+ :changed_date: '2020-10-31'
99
+ :90G0:
100
+ :name: General Officer
101
+ :91C0:
102
+ :name: Commander
103
+ :91E0:
104
+ :name: Wing Commander Equivalent
105
+ :91W0:
106
+ :name: Wing Commander
107
+ :92F0:
108
+ :name: Foreign Area Officer (FAO) Trainee
109
+ :92J0:
110
+ :name: Non-designated Lawyer
111
+ :92J1:
112
+ :name: AFROTC Educational Delay Law Student
113
+ :92J2:
114
+ :name: Funded Legal Education Program Law Student
115
+ :92J3:
116
+ :name: Excess Leave Law Student
117
+ :92M0:
118
+ :name: Health Professions Scholarship Program (HPSP) Medical Student
119
+ :92M1:
120
+ :name: Uniformed Services University of Health Sciences (USUHS) Student
121
+ :92M2:
122
+ :name: HPSP Biomedical Science Student
123
+ :92P0:
124
+ :name: Physician Assistant Student
125
+ :changed_date: '2017-10-31'
126
+ :92R0:
127
+ :name: Chaplain Candidate
128
+ :92S0:
129
+ :name: Student Officer Authorization
130
+ :92T0:
131
+ :name: Pilot Trainee
132
+ :92T1:
133
+ :name: Combat Systems Officer Trainee
134
+ :92T2:
135
+ :name: Air Battle Manager Trainee
136
+ :92T3:
137
+ :name: Remotely Piloted Aircraft Pilot Trainee
138
+ :92W0:
139
+ :name: Combat Wounded Warrior
140
+ :changed_date: '2015-05-11'
141
+ :92W1:
142
+ :name: Reserved for Future Use
143
+ :changed_date: '2015-05-11'
144
+ :92W2:
145
+ :name: Combat Wounded Warrior with Exemptions
146
+ :changed_date: '2015-05-11'
147
+ :92W3:
148
+ :name: Non-Combat Wounded Warrior
149
+ :changed_date: '2015-05-11'
150
+ :92W4:
151
+ :name: Wounded Warrior - Limited Assignment Status (LAS)
152
+ :acronym: LAS
153
+ :changed_date: '2015-05-11'
154
+ :92W5:
155
+ :name: Wounded Warrior - Retired/Discharged
156
+ :changed_date: '2015-05-11'
157
+ :92W6:
158
+ :name: Reserved for Future Use
159
+ :changed_date: '2015-05-11'
160
+ :92W7:
161
+ :name: Reserved for Future Use
162
+ :changed_date: '2015-05-11'
163
+ :92W8:
164
+ :name: Wounded Warrior - Ambassador
165
+ :changed_date: '2019-10-31'
166
+ :92W9:
167
+ :name: Wounded Warrior - Project Planner/Officer
168
+ :changed_date: '2019-10-31'
169
+ :93P0:
170
+ :name: Patient
171
+ :94N0:
172
+ :name: Nuclear Weapons Custodian
173
+ :95A0:
174
+ :name: Non-EAD USAFR Academy Liaison Officer (ALO) or Civil Air Patrol Reserve Assistance
175
+ Program (CAPRAP) Officer
176
+ :96A0:
177
+ :name: Disqualified Officer, Reasons Beyond Their Control
178
+ :96B0:
179
+ :name: Disqualified Officer, Reasons Within Their Control
180
+ :96D0:
181
+ :name: Officer Not Available for Use in Awarded AFSC for Cause
182
+ :96U0:
183
+ :name: Unclassified Officer
184
+ :96V0:
185
+ :name: Un-allotted
186
+ :97E0:
187
+ :name: Executive Officer
188
+ :99A0:
189
+ :name: Unspecified AFSC
190
+ :changed_date: '2015-04-20'
191
+ :99G0:
192
+ :name: Gold Bar Recruiter
193
+ :changed_date: '2013-06-05'
@@ -0,0 +1,219 @@
1
+ require "date"
2
+ require "yaml"
3
+
4
+ module GovCodes
5
+ module AFSC
6
+ # Loads the versioned release manifest and per-release specialty index for a
7
+ # publication (DAFECD enlisted, DAFOCD officer), and resolves the release in
8
+ # effect on a given date. Both publications share this resolve/merge/cache
9
+ # machinery and are resolved independently (their release dates may diverge).
10
+ #
11
+ # See docs/plans/2026-07-08-afsc-pdf-representation-design.md
12
+ # "Versioning by document release".
13
+ #
14
+ # Storage layout (per release):
15
+ # lib/gov_codes/afsc/releases.yml # manifest
16
+ # lib/gov_codes/afsc/releases/dafecd/<date>/enlisted.yml # enlisted index
17
+ # lib/gov_codes/afsc/releases/dafocd/<date>/officer.yml # officer index
18
+ #
19
+ # Resolution: releases are sorted ascending by effective_date. A given
20
+ # `as_of` resolves to the release with the greatest effective_date <= as_of;
21
+ # `as_of: nil` resolves it as of today. A release only takes effect on its
22
+ # own effective_date -- a release dated in the future (e.g. one a consumer
23
+ # pre-loads ahead of its official date, see DEC-004 below) is not resolved
24
+ # until that date arrives, even though it is the most recently added one. A
25
+ # date before the earliest shipped release resolves to no release (an empty
26
+ # index / nil date).
27
+ #
28
+ # Extensibility (DEC-004): both tiers merge across the load path (gem lib dir
29
+ # first, then the load path). The manifest's per-publication release list is
30
+ # unioned by effective_date -- a consumer releases.yml ADDS releases without
31
+ # hiding the shipped ones, and a same-date entry from the load path wins. For
32
+ # the resolved release, the release index found at each lookup path is merged,
33
+ # so a consumer may drop gov_codes/afsc/releases/dafecd/<date>/enlisted.yml to
34
+ # extend or override the shipped index.
35
+ module Releases
36
+ # Publication key for the enlisted directory (DAFECD).
37
+ ENLISTED_PUBLICATION = "dafecd"
38
+ # Publication key for the officer directory (DAFOCD).
39
+ OFFICER_PUBLICATION = "dafocd"
40
+
41
+ class << self
42
+ def manifest(lookup: $LOAD_PATH)
43
+ manifest_cache[cache_key(lookup)] ||= load_manifest(lookup)
44
+ end
45
+
46
+ def enlisted_index(as_of: nil, lookup: $LOAD_PATH)
47
+ release_index(ENLISTED_PUBLICATION, "enlisted.yml", as_of: as_of, lookup: lookup)
48
+ end
49
+
50
+ def officer_index(as_of: nil, lookup: $LOAD_PATH)
51
+ release_index(OFFICER_PUBLICATION, "officer.yml", as_of: as_of, lookup: lookup)
52
+ end
53
+
54
+ # Resolve the RI/SDI index for +publication+ (enlisted DAFECD or officer
55
+ # DAFOCD). RI/SDI codes live in both directories with different code
56
+ # grammars (5-char enlisted, 4-char officer), so this wrapper is
57
+ # parameterized by publication; it otherwise reuses the same resolve/
58
+ # merge/cache and acronym-overlay machinery as enlisted_index/
59
+ # officer_index.
60
+ def ri_index(as_of: nil, lookup: $LOAD_PATH, publication: ENLISTED_PUBLICATION)
61
+ release_index(publication, "ri.yml", as_of: as_of, lookup: lookup)
62
+ end
63
+
64
+ # Resolve the effective date for +publication+ (defaults to enlisted).
65
+ # Publications are resolved independently: an officer release date never
66
+ # moves the enlisted latest date and vice versa.
67
+ def effective_date_for(as_of: nil, lookup: $LOAD_PATH, publication: ENLISTED_PUBLICATION)
68
+ release = resolve_release(publication, as_of: as_of, lookup: lookup)
69
+ release && release[:date]
70
+ end
71
+
72
+ def reset!
73
+ @manifest_cache = {}
74
+ @index_cache = {}
75
+ end
76
+
77
+ private
78
+
79
+ def manifest_cache
80
+ @manifest_cache ||= {}
81
+ end
82
+
83
+ def index_cache
84
+ @index_cache ||= {}
85
+ end
86
+
87
+ def cache_key(lookup)
88
+ Array(lookup).dup
89
+ end
90
+
91
+ def to_date(as_of)
92
+ return nil if as_of.nil?
93
+ return as_of if as_of.is_a?(Date)
94
+ Date.parse(as_of.to_s)
95
+ rescue Date::Error
96
+ raise ArgumentError,
97
+ "invalid as_of #{as_of.inspect}: expected a Date or a parseable date string (e.g. \"2025-10-31\")"
98
+ end
99
+
100
+ # Ordered list of directories to search: the gem lib dir first, then the
101
+ # provided lookup path. Deduplicated to avoid loading the same file twice.
102
+ def lookup_paths(lookup)
103
+ gem_lib_dir = File.expand_path("../..", __dir__)
104
+ ([gem_lib_dir] + Array(lookup)).uniq
105
+ end
106
+
107
+ # Resolve, merge, and cache the release index for +publication+. Always
108
+ # resolves the release first (cheap: the manifest itself is cached) and
109
+ # keys the expensive part -- loading and merging the index file -- on
110
+ # the RESOLVED release's date, not the raw +as_of+. With `as_of: nil`
111
+ # meaning "today," keying on the raw input would freeze the cache to
112
+ # whichever release happened to be current at the first call and never
113
+ # notice a later release's effective_date arrive.
114
+ def release_index(publication, index_file, as_of:, lookup:)
115
+ release = resolve_release(publication, as_of: as_of, lookup: lookup)
116
+ return {} unless release
117
+
118
+ key = [publication, index_file, release[:date], cache_key(lookup)]
119
+ index_cache[key] ||= load_release_index(release, publication, index_file, lookup)
120
+ end
121
+
122
+ def resolve_release(publication, as_of:, lookup:)
123
+ releases = release_list(lookup, publication)
124
+ return nil if releases.empty?
125
+
126
+ target = to_date(as_of) || Date.today
127
+ releases.reverse.find { |r| r[:date] <= target }
128
+ end
129
+
130
+ # Releases for +publication+ from the manifest, each annotated with a
131
+ # parsed :date and the :dir name used to locate its index, sorted
132
+ # ascending by date.
133
+ def release_list(lookup, publication)
134
+ entries = manifest(lookup: lookup)[publication.to_sym] || []
135
+ entries.filter_map do |entry|
136
+ raw = entry[:effective_date]
137
+ next unless raw
138
+
139
+ date = raw.is_a?(Date) ? raw : Date.parse(raw.to_s)
140
+ dir = raw.is_a?(Date) ? raw.strftime("%Y-%m-%d") : raw.to_s
141
+ entry.merge(date: date, dir: dir)
142
+ end.sort_by { |r| r[:date] }
143
+ end
144
+
145
+ def load_manifest(lookup)
146
+ merged = {}
147
+ each_yaml(lookup, ["gov_codes", "afsc", "releases.yml"]) do |data|
148
+ merge_manifest!(merged, data)
149
+ end
150
+ merged
151
+ end
152
+
153
+ def load_release_index(release, publication, index_file, lookup)
154
+ merged = {}
155
+ parts = ["gov_codes", "afsc", "releases", publication, release[:dir], index_file]
156
+ each_yaml(lookup, parts) { |data| merged.merge!(data) }
157
+ apply_acronym_overlay(merged, publication, release, lookup)
158
+ merged
159
+ end
160
+
161
+ # Apply the consumer acronym overlay (Task 3, "dedicated tier"): a flat
162
+ # SPECIALTY => ACRONYM map at releases/<pub>/<dir>/acronyms.yml, resolved
163
+ # for the SAME release as the index and merged across the load path
164
+ # (consumer-loaded-last wins). Applied ONLY to specialties already in the
165
+ # index, so it sets an entry's :acronym without ever replacing the entry
166
+ # (the index merge above is shallow — overlaying whole entries here would
167
+ # drop name/skill_levels/shredouts). The consumer overlay wins over the
168
+ # source-extracted acronym shipped in the index. The gem ships no
169
+ # acronyms.yml; an absent overlay leaves the index unchanged. Resolved per
170
+ # release, so overlays never leak across document dates or publications.
171
+ def apply_acronym_overlay(merged, publication, release, lookup)
172
+ overlay = {}
173
+ parts = ["gov_codes", "afsc", "releases", publication, release[:dir], "acronyms.yml"]
174
+ each_yaml(lookup, parts) { |data| overlay.merge!(data) }
175
+ overlay.each do |specialty, acronym|
176
+ entry = merged[specialty]
177
+ entry[:acronym] = acronym if entry
178
+ end
179
+ end
180
+
181
+ # Merge a manifest hash into +acc+. Per-publication release lists are
182
+ # unioned by effective_date (a same-date entry from a later-loaded file
183
+ # wins); non-list values fall back to a plain overwrite.
184
+ def merge_manifest!(acc, incoming)
185
+ incoming.each do |publication, releases|
186
+ acc[publication] = if acc[publication].is_a?(Array) && releases.is_a?(Array)
187
+ union_releases(acc[publication], releases)
188
+ else
189
+ releases
190
+ end
191
+ end
192
+ acc
193
+ end
194
+
195
+ def union_releases(base, overrides)
196
+ by_date = {}
197
+ (base + overrides).each do |entry|
198
+ by_date[entry[:effective_date]] = entry if entry.is_a?(Hash)
199
+ end
200
+ by_date.values
201
+ end
202
+
203
+ # Yield each parsed YAML hash found at +parts+ across the lookup paths,
204
+ # skipping missing files and malformed YAML (graceful degradation).
205
+ def each_yaml(lookup, parts)
206
+ lookup_paths(lookup).each do |dir|
207
+ path = File.join(dir, *parts)
208
+ next unless File.exist?(path)
209
+
210
+ data = YAML.load_file(path, symbolize_names: true)
211
+ yield data if data.is_a?(Hash)
212
+ rescue Psych::SyntaxError, TypeError
213
+ next
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,11 @@
1
+ ---
2
+ :dafecd:
3
+ - :effective_date: '2025-10-31'
4
+ :version_label: v3.5
5
+ :source: DAFECD -31 October 25 v3.5 FINAL.pdf
6
+ :name: Department of the Air Force Enlisted Classification Directory
7
+ :dafocd:
8
+ - :effective_date: '2025-10-31'
9
+ :version_label: v3
10
+ :source: DAFOCD 31 Oct 25 v3.pdf
11
+ :name: Department of the Air Force Officer Classification Directory