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
@@ -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
@@ -0,0 +1,207 @@
1
+ require "strscan"
2
+ require_relative "releases"
3
+
4
+ module GovCodes
5
+ module AFSC
6
+ # Reporting Identifiers (RI) and Special Duty Identifiers (SDI), resolved
7
+ # against the versioned classification-directory release indexes. RI/SDI
8
+ # codes live in BOTH directories under two different code grammars:
9
+ #
10
+ # Enlisted (DAFECD), 5-char: \d[A-Z]\d{3}[A-Z]? e.g. 9Z200, 8R300A
11
+ # Officer (DAFOCD), 4-char: \d{2}[A-Z]\d[A-Z]? e.g. 90G0, 92T1
12
+ #
13
+ # A single +find+ entry point dispatches by shape to the matching
14
+ # publication's ri.yml index (Releases.ri_index). Entries share the shape of
15
+ # enlisted/officer specialty records (:name, :acronym, :shredouts,
16
+ # :shredout_acronyms), so shredout suffixes and acronyms resolve exactly as
17
+ # they do for Enlisted/Officer.
18
+ module RI
19
+ class Parser
20
+ def initialize(code)
21
+ @code = code.to_s
22
+ end
23
+
24
+ # Try the 5-char enlisted shape first, then the 4-char officer shape.
25
+ # Both shapes are mutually exclusive (an enlisted code's 2nd char is a
26
+ # letter; an officer code's 2nd char is a digit), so at most one matches.
27
+ # The result carries the decomposed fields plus the :publication whose
28
+ # index should resolve the name; all nil when neither shape parses.
29
+ def parse
30
+ parse_enlisted || parse_officer || empty_result
31
+ end
32
+
33
+ private
34
+
35
+ def empty_result
36
+ {
37
+ career_group: nil,
38
+ career_field: nil,
39
+ identifier: nil,
40
+ suffix: nil,
41
+ specific_ri: nil,
42
+ publication: nil
43
+ }
44
+ end
45
+
46
+ # Enlisted shape: career_group (1 digit), career_field (digit+letter,
47
+ # e.g. "9Z"), identifier (3 digits), optional 1-letter suffix.
48
+ def parse_enlisted
49
+ scanner = StringScanner.new(@code)
50
+ career_group = scanner.scan(/\d/) or return nil
51
+ field_letter = scanner.scan(/[A-Z]/) or return nil
52
+ identifier = scanner.scan(/\d{3}/) or return nil
53
+ suffix = scanner.scan(/[A-Z]/)
54
+ return nil unless scanner.eos?
55
+
56
+ career_field = :"#{career_group}#{field_letter}"
57
+ {
58
+ career_group: career_group.to_sym,
59
+ career_field: career_field,
60
+ identifier: identifier.to_sym,
61
+ suffix: suffix&.to_sym,
62
+ specific_ri: :"#{career_field}#{identifier}",
63
+ publication: Releases::ENLISTED_PUBLICATION
64
+ }
65
+ end
66
+
67
+ # Officer shape: same grammar as an officer AFSC (2-digit career group +
68
+ # functional-area letter + 1-digit qualification level). There is no
69
+ # natural 3-digit identifier here, so these codes are mapped onto the
70
+ # SAME Code struct with the closest-matching semantics:
71
+ # career_group the 2-digit prefix (e.g. :"90")
72
+ # career_field 2-digit + letter (e.g. :"90G")
73
+ # identifier the trailing digit (e.g. :"0") <- 1 digit, not 3;
74
+ # intentional, not a bug
75
+ # specific_ri the full 4-char code (e.g. :"90G0")
76
+ def parse_officer
77
+ scanner = StringScanner.new(@code)
78
+ career_group = scanner.scan(/\d{2}/) or return nil
79
+ field_letter = scanner.scan(/[A-Z]/) or return nil
80
+ identifier = scanner.scan(/\d/) or return nil
81
+ suffix = scanner.scan(/[A-Z]/)
82
+ return nil unless scanner.eos?
83
+
84
+ career_field = :"#{career_group}#{field_letter}"
85
+ {
86
+ career_group: career_group.to_sym,
87
+ career_field: career_field,
88
+ identifier: identifier.to_sym,
89
+ suffix: suffix&.to_sym,
90
+ specific_ri: :"#{career_field}#{identifier}",
91
+ publication: Releases::OFFICER_PUBLICATION
92
+ }
93
+ end
94
+ end
95
+
96
+ CODES = {}
97
+ private_constant :CODES
98
+
99
+ Code = Data.define(
100
+ :career_group,
101
+ :career_field,
102
+ :identifier,
103
+ :suffix,
104
+ :specific_ri,
105
+ :name,
106
+ :acronym,
107
+ :effective_date
108
+ )
109
+
110
+ # Resolve an RI/SDI code against the release in effect on +as_of+ (default:
111
+ # today). Dispatches by code shape to the enlisted (DAFECD) or officer
112
+ # (DAFOCD) ri.yml index. Returns nil when the code parses as neither shape,
113
+ # when the resolved index lacks the code, or when +as_of+ precedes the
114
+ # earliest shipped release for the relevant publication.
115
+ def self.find(code, as_of: nil)
116
+ code = code.to_s
117
+ parsed = Parser.new(code).parse
118
+ publication = parsed[:publication]
119
+ return nil unless publication
120
+
121
+ # Key the memo on the RESOLVED release date so equivalent as_of values
122
+ # (nil, the Date, its string form) share one slot.
123
+ effective_date = Releases.effective_date_for(as_of: as_of, publication: publication)
124
+ CODES[[code, effective_date]] ||= begin
125
+ index = Releases.ri_index(as_of: as_of, publication: publication)
126
+ entry = index[parsed[:specific_ri]]
127
+ return nil unless entry
128
+
129
+ suffix = parsed[:suffix]
130
+ # Shredout suffix meaning, only when the directory documents it.
131
+ shredout_name = suffix && entry.dig(:shredouts, suffix)
132
+ name = shredout_name || entry[:name]
133
+ return nil if name.nil?
134
+
135
+ # A documented shredout acronym wins for a shredded code; otherwise the
136
+ # entry's own acronym (which a consumer overlay may set).
137
+ shredout_acronym = suffix && entry.dig(:shredout_acronyms, suffix)
138
+
139
+ Code.new(
140
+ career_group: parsed[:career_group],
141
+ career_field: parsed[:career_field],
142
+ identifier: parsed[:identifier],
143
+ suffix: suffix,
144
+ specific_ri: parsed[:specific_ri],
145
+ name: name,
146
+ acronym: shredout_acronym || entry[:acronym],
147
+ effective_date: effective_date
148
+ )
149
+ end
150
+ end
151
+
152
+ # Resolve the RI/SDI code whose acronym matches +acronym+ (case-insensitive)
153
+ # in the release in effect on +as_of+. Scans the enlisted index first, then
154
+ # the officer index; within an entry the entry's own acronym is tried before
155
+ # its shredout acronyms (a shredout match returns the concrete shredded
156
+ # code). First match wins. Returns nil when no acronym in the resolved
157
+ # release(s) matches (no leakage across document dates).
158
+ def self.find_by_acronym(acronym, as_of: nil)
159
+ acronym = acronym.to_s.upcase
160
+ return nil if acronym.empty?
161
+
162
+ [Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
163
+ index = Releases.ri_index(as_of: as_of, publication: publication)
164
+ index.each do |code, entry|
165
+ return find(code.to_s, as_of: as_of) if entry[:acronym].to_s.upcase == acronym
166
+
167
+ (entry[:shredout_acronyms] || {}).each do |suffix, acr|
168
+ return find("#{code}#{suffix}", as_of: as_of) if acr.to_s.upcase == acronym
169
+ end
170
+ end
171
+ end
172
+ nil
173
+ end
174
+
175
+ # Walk both publications' RI/SDI indexes for the release in effect on
176
+ # +as_of+, emitting each base code and its shredout-suffixed combinations,
177
+ # and return the Codes whose code starts with +prefix+.
178
+ def self.search(prefix, as_of: nil)
179
+ prefix = prefix.to_s.upcase
180
+
181
+ codes = []
182
+ [Releases::ENLISTED_PUBLICATION, Releases::OFFICER_PUBLICATION].each do |publication|
183
+ index = Releases.ri_index(as_of: as_of, publication: publication)
184
+ index.each do |code, entry|
185
+ code_str = code.to_s
186
+ codes << code_str
187
+ (entry[:shredouts] || {}).each_key do |suffix|
188
+ codes << "#{code_str}#{suffix}"
189
+ end
190
+ end
191
+ end
192
+
193
+ codes.select { |code| code.start_with?(prefix) }
194
+ .map { |code| find(code, as_of: as_of) }
195
+ .compact
196
+ end
197
+
198
+ # Clears the memoized lookups and resets the versioned release loader. The
199
+ # +lookup+ keyword is accepted for interface parity with Enlisted/Officer;
200
+ # the versioned index is resolved from the load path at lookup time.
201
+ def self.reset_data(lookup: $LOAD_PATH)
202
+ Releases.reset!
203
+ CODES.clear
204
+ end
205
+ end
206
+ end
207
+ end
@@ -1,16 +1,48 @@
1
1
  require_relative "afsc/enlisted"
2
2
  require_relative "afsc/officer"
3
+ require_relative "afsc/ri"
3
4
 
4
5
  module GovCodes
5
6
  module AFSC
6
- def self.find(code)
7
- AFSC::Enlisted.find(code) ||
8
- AFSC::Officer.find(code)
7
+ # Resolve a code as of the classification-directory release in effect on
8
+ # +as_of+ (default: today). +as_of+ applies to every tier: the versioned
9
+ # enlisted (DAFECD) and officer (DAFOCD) AFSC lookups and the versioned
10
+ # RI/SDI lookup (which itself dispatches by code shape to the DAFECD or
11
+ # DAFOCD RI index), each resolved against its own publication.
12
+ def self.find(code, as_of: nil)
13
+ AFSC::Enlisted.find(code, as_of: as_of) ||
14
+ AFSC::Officer.find(code, as_of: as_of) ||
15
+ AFSC::RI.find(code, as_of: as_of)
16
+ end
17
+
18
+ # Resolve a code by its acronym (case-insensitive) as of the release in
19
+ # effect on +as_of+ (default: today). Searches the enlisted tier
20
+ # (source-verified acronyms + that release's overlay), then the officer
21
+ # tier (specialty and shredout acronyms + that release's overlay), then the
22
+ # versioned RI/SDI tier (enlisted then officer RI index for that release).
23
+ # Returns a single Code or nil.
24
+ #
25
+ # Acronyms are expected to be unique. If two codes carry the same acronym,
26
+ # the first match wins in a defined order: enlisted before officer before RI,
27
+ # and within a tier by the data's key order.
28
+ def self.find_by_acronym(acronym, as_of: nil)
29
+ AFSC::Enlisted.find_by_acronym(acronym, as_of: as_of) ||
30
+ AFSC::Officer.find_by_acronym(acronym, as_of: as_of) ||
31
+ AFSC::RI.find_by_acronym(acronym, as_of: as_of)
32
+ end
33
+
34
+ def self.search(prefix, as_of: nil)
35
+ results = []
36
+ results.concat(Enlisted.search(prefix, as_of: as_of))
37
+ results.concat(Officer.search(prefix, as_of: as_of))
38
+ results.concat(RI.search(prefix, as_of: as_of))
39
+ results
9
40
  end
10
41
 
11
42
  def self.reset_data(lookup: $LOAD_PATH)
12
43
  Enlisted.reset_data(lookup:)
13
44
  Officer.reset_data(lookup:)
45
+ RI.reset_data(lookup:)
14
46
  end
15
47
  end
16
48
  end