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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patterns"
4
+
5
+ module GovCodes
6
+ module Dafecd
7
+ module RiSdi
8
+ # Conservative title normalization for RI/SDI records.
9
+ #
10
+ # These helpers mirror the AFSC SpecialtyParser's title handling exactly
11
+ # (sanitize -> title-case verbatim, flag probable pdf glue) but live in
12
+ # their own module so the RI/SDI extraction shares NONE of the AFSC ladder
13
+ # pipeline's mutable state — the already-shipped enlisted/officer artifacts
14
+ # cannot be perturbed by anything done here.
15
+ #
16
+ # As in the AFSC pipeline, no de-gluing is attempted: a run-together title
17
+ # like "ENLISTEDAIDE" becomes "Enlistedaide" and is flagged via #glued? for
18
+ # the deferred, governed de-glue pass. Clean titles are authored out of band.
19
+ module Title
20
+ DECORATIVE = Patterns::DECORATIVE
21
+ UNICODE_DASHES = Patterns::UNICODE_DASHES
22
+
23
+ module_function
24
+
25
+ # Remove decorative symbol glyphs, normalize unicode dashes, and collapse
26
+ # whitespace runs so a title line reduces to its plain-text content.
27
+ def sanitize(line)
28
+ line.gsub(DECORATIVE, "").gsub(UNICODE_DASHES, "-").gsub(/\s+/, " ").strip
29
+ end
30
+
31
+ # A title line is short, starts with a capital / paren / digit, and is not
32
+ # a prose sentence. Accepts ALL-CAPS and Title-Case forms.
33
+ def title_line?(stripped)
34
+ return false if stripped.length > 90
35
+ return false unless stripped.match?(/\A[A-Z0-9(]/)
36
+ return false if stripped.split(/\s+/).size > 12
37
+ return false if stripped.match?(/[a-z]\.\s+[A-Z]/) # mid-line sentence break = prose
38
+ stripped.match?(/[A-Za-z]/)
39
+ end
40
+
41
+ # Conservative title-case: capitalize each alphabetic word, preserving
42
+ # parenthesized acronyms and all-caps-with-digit tokens.
43
+ def titleize(raw)
44
+ raw.split(/\s+/).map { |word| titleize_word(word) }.join(" ")
45
+ end
46
+
47
+ def titleize_word(word)
48
+ return word if word.match?(/\A\([A-Z0-9&\/.-]{2,}\)\z/)
49
+ return word if word.match?(/\A[A-Z]*\d[A-Z0-9]*\z/) && word.match?(/[A-Z0-9]/)
50
+
51
+ word.split(/([\/-])/).map { |seg|
52
+ seg.match?(/[A-Za-z]/) ? seg.capitalize : seg
53
+ }.join
54
+ end
55
+
56
+ # A real title starts with a capital letter, a digit, or an opening paren
57
+ # — never the lowercase word that begins a wrapped prose sentence (the
58
+ # "SDI 8P000, completion of ..." false positive).
59
+ def real?(title)
60
+ t = title.to_s.strip
61
+ !t.empty? && t.match?(/\A[A-Z0-9(]/)
62
+ end
63
+
64
+ # Heuristic proxy for pdf-reader glue: an unusually long single token
65
+ # (excluding preserved acronyms). Reported, not relied upon.
66
+ def glued?(name)
67
+ return false if name.nil?
68
+ name.split(/\s+/).any? do |token|
69
+ bare = token.delete("()/-")
70
+ bare.length >= 12 && bare.match?(/\A[A-Za-z]+\z/)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,124 @@
1
+ # De-glued RI/SDI titles for DAFECD, effective 2025-10-31.
2
+ # Each value is the source PDF title with ONLY spacing/case adjusted --
3
+ # no letters, digits, or punctuation changed. Enforced at build time: the
4
+ # IndexBuilder verifies each override against the raw source title
5
+ # (whitespace/case-insensitive equality) and fails the build on drift.
6
+ :5I000: Intelligence, Surveillance & Reconnaissance Superintendent
7
+ :5Z700: Space Operations Senior Enlisted Leader (USSF Only)
8
+ :8A200: Enlisted Aide
9
+ :8A300: Protocol
10
+ :8A400: Talent Management Consultant
11
+ :8B000: Miltary Training Instructor
12
+ :8B100: Military Training Leader
13
+ :8B200: Academy Military Training NCO
14
+ :8B300: Officer Accessions Intructor
15
+ :8B400: Military Training Instructor (Space Force)
16
+ :8B500: Guardian Training Leader
17
+ :8C000: Military and Family Readiness Non-Commissioned Officer (RNCO)
18
+ :8D100: Language & Culture Advisor
19
+ :8F000: First Sergeant
20
+ :8F100: Guardian First Sergeant
21
+ :8G000: Premier Honor Guard
22
+ :8G100: Base Honor Guard Program Manager
23
+ :8G200: Premier Honor Guard (Space Force)
24
+ :8H000: Airmen Dorm Leader
25
+ :8I000: Superintendent, Inspector General
26
+ :8I100: Inspections Coordinator
27
+ :8I200: Complaints & Resolution Coordinator
28
+ :8K000: Software Development Specialist
29
+ :8L100: Air Advisor - Basic
30
+ :8L200: Air Advisor Basic, Team Sergeant
31
+ :8L300: Air Advisor Basic, Team Leader
32
+ :8L400: Air Advisor Advanced
33
+ :8L500: Air Advisor Advanced, Team Sergeant
34
+ :8L600: Air Advisor Advanced, Team Leader
35
+ :8L700: Combat Aviation Advisor
36
+ :8L800: Combat Aviation Advisor Team Sergeant
37
+ :8L900: Combat Aviation Advisor Team Leader
38
+ :8P000: Courier
39
+ :8P100: Defense Attaché
40
+ :8R000: Enlisted Accessions Recruiter
41
+ :8R100: Guardian Talent Scout
42
+ :8R200: Second-Tier Recruiter
43
+ :8R300: Third-Tier Recruiter
44
+ :8S000: Missile Facility Manager
45
+ :8S200: Combat Crew Communications
46
+ :8T000: Professional Military Education Instructor
47
+ :8T100: Enlisted Professional Military Education Instructional System Designer
48
+ :8T200: Development Advisor
49
+ :8T300: Professional Military Education Instructor (Space Force)
50
+ :8U000: Unit Deployment Manager
51
+ :8U100: Weapons of Mass Destruction Civil Support Team (WMD-CST)
52
+ :8W000: Weapons Safety Manager - Wing Level
53
+ :8W100: Weapons Safety Manager
54
+ :8W200: Second-Tier Weapons Safety Manager
55
+ :8W300: Third-Tier Weapons Safety Manager
56
+ :8X100: Operational STEM Specialist
57
+ :8Y000: Pathfinder
58
+ :9A000: Enlisted Airman/Guardian - Disqualified for Reasons Beyond Control
59
+ :9A100: Enlisted Airman/Guardian - Disqualified for Reasons Within Control
60
+ :9A200: Enlisted Airman/Guardian Awaiting Discharge, Separation, or Retirement for
61
+ Reasons Within Their Control
62
+ :9A300: Enlisted Airman/Guardian Awaiting Discharge, Separation, or Retirement for
63
+ Reasons Beyond Their Control
64
+ :9A400: Disqualified Airman/Guardian, Return to Duty Program
65
+ :9A500: Enlisted Airman/Guardian Temporarily Ineligible for Retraining - Disqualified
66
+ for Reasons Beyond Control
67
+ :9B000: Senior Enlisted Advisor to the Chairman of the Joint Chiefs of Staff
68
+ :9B100: Senior Enlisted Advisor to the Chief of the National Guard Bureau (CNGB)
69
+ :9C000: Chief Master Sergeant of the Air Force
70
+ :9C100: Executive Assistant to the Chief Master Sergeant of the Air Force
71
+ :9D100: AF Developmental Senior Enlisted Positions
72
+ :9D200: Key Developmental Senior Enlisted Positions
73
+ :9D300: Wing (Wing level) or Headquarter (HQ level) Senior Enlisted Leader
74
+ :9E000: Command Chief Master Sergeant
75
+ :9E100: Command Chief Executive Assistant
76
+ :9E200: Individual Mobilization Augmentee to Command Chief Master Sergeant
77
+ :9F000: First Term Enlisted Course (FTEC) NCOIC
78
+ :9G100: Group Senior Enlisted Leader
79
+ :9H000: Academic Faculty Instructor
80
+ :9H100: White House Communications Agency Technician (WHCA)
81
+ :9H200: Academic Faculty Instructor (Space Force)
82
+ :9I000: Futures Airmen
83
+ :9J000: Prisoner
84
+ :9L000: Interpreter/Translator
85
+ :9L100: International Affairs Specialist
86
+ :9M000: Military Entrance Processing Command (MEPCOM) Senior Enlisted Advisor
87
+ :9M200: International Health Specialists (IHS)
88
+ :9M400: Chief, Medical Enlisted Force (CMEF), Senior Enlisted Leader and/or Senior
89
+ Enlisted Advisor (SEA)
90
+ :9N000: Secretary of the Air Force Enlisted Legislative Fellows
91
+ :9N100: Secretary of the Air Force Enlisted Legislative Fellows (Space Force)
92
+ :9N200: DAF World Class Athlete Program (WCAP)
93
+ :9P000: Patient
94
+ :9Q000: Reserve Force Generation and Oversight NCO
95
+ :9R000: Civil Air Patrol (CAP)-USAF Reserve Assistance NCO
96
+ :9S000: Chief Master Sergeant of the Space Force
97
+ :9S100: Scientific Applications Specialist
98
+ :9T000: Basic Enlisted Airman
99
+ :9T100: Officer Trainee
100
+ :9T200: Precadet Assignee
101
+ :9T400: AFIT/EWI Enlisted Students
102
+ :9T500: Basic Special Warfare Enlisted Airman
103
+ :9U000: Enlisted Airman/Guardian Ineligible for Local Utilization
104
+ :9U100: Unallotted Enlisted Authorization
105
+ :9V000: Key Developmental Joint Senior Enlisted Position
106
+ :9V100: Executive Assistant to the Senior Enlisted Advisor to the Chairman of the
107
+ Joint Chiefs of Staff
108
+ :9W000: Combat Wounded Warrior
109
+ :9W100: Reserved for Future Use
110
+ :9W200: Combat Wounded Warrior with Exemptions
111
+ :9W300: Non-Combat Wounded Warrior
112
+ :9W400: Wounded Warrior - Limited Assignment Status (LAS)
113
+ :9W500: Reserved for Future Use
114
+ :9W600: Reserved for Future Use
115
+ :9W700: Reserved for Future Use
116
+ :9W800: Wounded Warrior - Ambassador
117
+ :9W900: Wounded Warrior - Project Planner/Officer
118
+ :9Y000: Air Force Parachute Team (AFPT) Instructor
119
+ :9Z000: Special Warfare Mission Support (SWMS) Career Field Manager (CFM) on Headquarters
120
+ Air Force Staff, Air Force Special Warfare Division
121
+ :9Z100: Special Warfare Mission Support (SWMS) Senior Enlisted Leader, Air Force Special
122
+ Warfare (AFSPECWAR)
123
+ :9Z200: Special Warfare Mission Support (SWMS) Superintendent, Air Force Special Warfare
124
+ (AFSPECWAR)
@@ -0,0 +1,78 @@
1
+ # De-glued RI/SDI titles for DAFOCD, effective 2025-10-31.
2
+ # Each value is the source PDF title with ONLY spacing/case adjusted --
3
+ # no letters, digits, or punctuation changed. Enforced at build time: the
4
+ # IndexBuilder verifies each override against the raw source title
5
+ # (whitespace/case-insensitive equality) and fails the build on drift.
6
+ :80C0: Commander, Cadet Squadron, USAF Academy
7
+ :81C0: Instructor, Officer Training School
8
+ :81D0: Air Force Reserve Officer Training Corps Detachment Commander and Professor
9
+ of Aerospace Studies
10
+ :81L0: Education and Training Leader
11
+ :81T0: Instructor
12
+ :82A0: Academic Program Manager
13
+ :83R0: Recruiting Service
14
+ :84H0: Historian
15
+ :85G0: Honor Guard
16
+ :86M0: Operations Management
17
+ :86P0: Command and Control
18
+ :87G0: Wing Inspector General
19
+ :87I0: Director, Wing Inspections
20
+ :87Q0: Director, Complaints Resolution
21
+ :88A0: Aide-de-camp
22
+ :88B0: Protocol Officer
23
+ :88C0: Sexual Assault Response Coordinator (SARC)
24
+ :88I0: Innovation Officer
25
+ :88M0: United States Air Force Medical Pinnacle Position
26
+ :88X0: Operational STEM Officer
27
+ :88Y0: Pathfinder Officer
28
+ :89A0: Air Advisor (Basic)
29
+ :89B0: Air Advisor (Basic) Team Leader
30
+ :89C0: Air Advisor (Basic) Mission Commander
31
+ :89D0: Air Advisor (Advanced)
32
+ :89E0: Air Advisor (Advanced) Team Leader
33
+ :89F0: Air Advisor (Advanced) Mission Commander
34
+ :89G0: Combat Aviation Advisor
35
+ :89H0: Combat Aviation Advisor Team Leader
36
+ :89I0: Combat Aviation Advisor Mission Commander
37
+ :89W0: Weapons of Mass Destruction Civil Support Team (WMD-CST)
38
+ :90G0: General Officer
39
+ :91C0: Commander
40
+ :91E0: Wing Commander Equivalent
41
+ :91W0: Wing Commander
42
+ :92F0: Foreign Area Officer (FAO) Trainee
43
+ :92J0: Non-designated Lawyer
44
+ :92J1: AFROTC Educational Delay Law Student
45
+ :92J2: Funded Legal Education Program Law Student
46
+ :92J3: Excess Leave Law Student
47
+ :92M0: Health Professions Scholarship Program (HPSP) Medical Student
48
+ :92M1: Uniformed Services University of Health Sciences (USUHS) Student
49
+ :92M2: HPSP Biomedical Science Student
50
+ :92P0: Physician Assistant Student
51
+ :92R0: Chaplain Candidate
52
+ :92S0: Student Officer Authorization
53
+ :92T0: Pilot Trainee
54
+ :92T1: Combat Systems Officer Trainee
55
+ :92T2: Air Battle Manager Trainee
56
+ :92T3: Remotely Piloted Aircraft Pilot Trainee
57
+ :92W0: Combat Wounded Warrior
58
+ :92W1: Reserved for Future Use
59
+ :92W2: Combat Wounded Warrior with Exemptions
60
+ :92W3: Non-Combat Wounded Warrior
61
+ :92W4: Wounded Warrior - Limited Assignment Status (LAS)
62
+ :92W5: Wounded Warrior - Retired/Discharged
63
+ :92W6: Reserved for Future Use
64
+ :92W7: Reserved for Future Use
65
+ :92W8: Wounded Warrior - Ambassador
66
+ :92W9: Wounded Warrior - Project Planner/Officer
67
+ :93P0: Patient
68
+ :94N0: Nuclear Weapons Custodian
69
+ :95A0: Non-EAD USAFR Academy Liaison Officer (ALO) or Civil Air Patrol Reserve Assistance
70
+ Program (CAPRAP) Officer
71
+ :96A0: Disqualified Officer, Reasons Beyond Their Control
72
+ :96B0: Disqualified Officer, Reasons Within Their Control
73
+ :96D0: Officer Not Available for Use in Awarded AFSC for Cause
74
+ :96U0: Unclassified Officer
75
+ :96V0: Un-allotted
76
+ :97E0: Executive Officer
77
+ :99A0: Unspecified AFSC
78
+ :99G0: Gold Bar Recruiter
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GovCodes
4
+ module Dafecd
5
+ # Extracts shredout-level acronyms for the officer directory from two
6
+ # deterministic sources (both grounded verbatim in the record source):
7
+ #
8
+ # a. Shredout TABLE values whose meaning ends in a trailing "(ACR)", e.g.
9
+ # "Air Liaison Officer (ALO)" -> {U: "ALO"}. The :shredouts value is kept
10
+ # verbatim; only the acronym is lifted out.
11
+ #
12
+ # b. A numbered in-record enumeration of the form
13
+ # "<family>X<shred> (<Title> (<ACR>))", e.g. in the 19Z record:
14
+ # "19ZXB (Tactical Air Control Party Officer (TACPO))" -> {B: "TACPO"}.
15
+ # Only codes belonging to the record's own specialty family are accepted.
16
+ module ShredoutAcronyms
17
+ # A shredout value's trailing parenthetical acronym.
18
+ TABLE_ACRONYM = /\(([A-Z][A-Z0-9]{1,7})\)\s*\z/
19
+
20
+ module_function
21
+
22
+ # @param shredouts [Hash{Symbol=>String}] parsed suffix => meaning
23
+ # @return [Hash{Symbol=>String}] suffix => trailing acronym
24
+ def from_table(shredouts)
25
+ result = {}
26
+ shredouts.each do |suffix, meaning|
27
+ match = meaning.match(TABLE_ACRONYM)
28
+ result[suffix] = match[1] if match
29
+ end
30
+ result
31
+ end
32
+
33
+ # @param record [String] the specialty record's source text
34
+ # @param family [String] the record's 3-char specialty family (e.g. "19Z")
35
+ # @return [Hash{Symbol=>String}] shred letter => acronym
36
+ def from_enumeration(record, family)
37
+ return {} if family.nil? || family.empty?
38
+ result = {}
39
+ pattern = /#{Regexp.escape(family)}X([A-Z])\s*\([^()]*\(([A-Z][A-Z0-9]{1,7})\)\)/
40
+ record.scan(pattern) do |shred, acronym|
41
+ result[shred.to_sym] = acronym
42
+ end
43
+ result
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GovCodes
6
+ module Dafecd
7
+ # Applies verified de-glued shredout values.
8
+ #
9
+ # pdf-reader drops spaces inside shredout meanings exactly as it does inside
10
+ # specialty titles ("ImageryAnalyst", "TacticalAir Control Party Officer").
11
+ # The deterministic extractor keeps those verbatim and cannot safely re-insert
12
+ # the missing spaces. The clean values are produced out of band and shipped in
13
+ # per-publication files under shredout_overrides/ (mirroring title_overrides/).
14
+ #
15
+ # The data is nested one level deeper than the title overrides: a specialty
16
+ # maps to a suffix => clean-value map, e.g.
17
+ #
18
+ # :1A1X8:
19
+ # :A: C-32/C-40B/C Flight Attendant
20
+ # :C: C-37A/B Flight Attendant
21
+ #
22
+ # The de-gluing invariant — enforced at build time by IndexBuilder — is that
23
+ # an override may only change SPACING and CASE relative to the raw extracted
24
+ # value; no letter, digit, or punctuation may change. Any drift (or an
25
+ # override targeting a shredout absent from the source) fails the build loudly
26
+ # rather than silently applying a stale value.
27
+ class ShredoutDegluer
28
+ # @return [ShredoutDegluer] loaded from +path+
29
+ def self.load(path)
30
+ overrides = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
31
+ new(overrides)
32
+ end
33
+
34
+ # @return [ShredoutDegluer] loaded from +publication+'s overrides file, or
35
+ # an empty de-gluer when that file does not exist.
36
+ def self.for(publication)
37
+ path = publication.shredout_overrides_path
38
+ File.exist?(path) ? load(path) : empty
39
+ end
40
+
41
+ # An empty de-gluer applies no overrides (keeps the verbatim values).
42
+ def self.empty
43
+ new({})
44
+ end
45
+
46
+ # @param overrides [Hash{Symbol=>Hash{Symbol=>String}}] specialty =>
47
+ # (suffix => clean value)
48
+ def initialize(overrides = {})
49
+ @overrides = overrides
50
+ end
51
+
52
+ # @return [Hash{Symbol=>String}, nil] suffix => clean value for +specialty+
53
+ def overrides_for(specialty)
54
+ @overrides[specialty]
55
+ end
56
+
57
+ # Yields every declared override so the build can verify each targets a
58
+ # shredout that actually exists (completeness gate).
59
+ def each_override
60
+ return enum_for(:each_override) unless block_given?
61
+ @overrides.each do |specialty, suffixes|
62
+ suffixes.each do |suffix, value|
63
+ yield specialty, suffix, value
64
+ end
65
+ end
66
+ end
67
+
68
+ def any?
69
+ !@overrides.empty?
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,27 @@
1
+ # De-glued shredout values for DAFECD, effective 2025-10-31.
2
+ # Each value is the source PDF shredout meaning with ONLY spacing/case
3
+ # adjusted -- no letters, digits, or punctuation changed. Enforced at build
4
+ # time: the IndexBuilder verifies each override against the raw extracted
5
+ # shredout value (whitespace-and-case-insensitive equality) and fails the
6
+ # build on drift or on an override targeting a shredout absent from the source.
7
+ # Nested one level deeper than title_overrides: specialty => (suffix => value).
8
+ :1A1X4:
9
+ :Z: Multi-domain Operations Aviator Data Mask
10
+ :1A1X8:
11
+ :A: C-32/C-40B/C Flight Attendant
12
+ :C: C-37A/B Flight Attendant
13
+ :H: E-4B Flight Attendant
14
+ :1D7X1:
15
+ :B: Systems Administration
16
+ :1N1X1:
17
+ :A: Imagery Analyst
18
+ :1N2X1:
19
+ :A: Electronic Non-Communications Analyst
20
+ :C: Signals Analyst
21
+ :1P0X1:
22
+ :A: Ejection Seat Aircraft
23
+ :B: Non-Ejection Seat Aircraft
24
+ :3F3X1:
25
+ :M: Management Engineering/Data Analytics
26
+ :3P0X1:
27
+ :B: Combat Arms
@@ -0,0 +1,27 @@
1
+ # De-glued shredout values for DAFOCD, effective 2025-10-31.
2
+ # Each value is the source PDF shredout meaning with ONLY spacing/case
3
+ # adjusted -- no letters, digits, or punctuation changed. Enforced at build
4
+ # time: the IndexBuilder verifies each override against the raw extracted
5
+ # shredout value (whitespace-and-case-insensitive equality) and fails the
6
+ # build on drift or on an override targeting a shredout absent from the source.
7
+ # Nested one level deeper than title_overrides: specialty => (suffix => value).
8
+ :11UX:
9
+ :K: Instructor for weapon system designated by AFSC
10
+ :V: Automated Functional Applications Analyst
11
+ :13BX:
12
+ :D: Mobile Air Control
13
+ :13SX:
14
+ :E: Space Access and Sustainment
15
+ :15WX:
16
+ :A: Advanced Weather Activities
17
+ :17YX:
18
+ :A: Cyber Warfare Analyst
19
+ :D: Cyber Threat Defense Analyst
20
+ :O: Cyber Attack Operator
21
+ :19ZX:
22
+ :B: Tactical Air Control Party Officer
23
+ :44KX:
24
+ :N: Child Abuse
25
+ :45BX:
26
+ :G: Replacement Arthroplasty
27
+ :J: Foot and Ankle Surgery
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+ require_relative "publication"
5
+
6
+ module GovCodes
7
+ module Dafecd
8
+ # Parses a "Specialty Shredouts" table into a suffix => name map.
9
+ #
10
+ # The table is laid out in two side-by-side columns, e.g. (enlisted):
11
+ #
12
+ # Suffix Primary Aircraft Suffix Primary Aircraft
13
+ # A C-5 Flight Engineer L C-130H Flight Engineer
14
+ # B C-5 Loadmaster N C-130H Loadmaster
15
+ #
16
+ # The officer directory uses the same two-column interleave under a different
17
+ # heading ("Suffix ... Portion of AFS to Which Related"). Both columns are
18
+ # captured. Values that wrap onto a continuation line are captured only up to
19
+ # the wrap (a documented, minor limitation). The table header is
20
+ # publication-specific (injected Publication); the default is enlisted.
21
+ class ShredoutParser
22
+ # A single suffix/name cell. The suffix is one capital letter followed by
23
+ # 2+ spaces; the name runs until 2+ spaces precede the next column's suffix
24
+ # or the line ends.
25
+ PAIR = /\b([A-Z])\s{2,}([A-Z0-9][A-Za-z0-9 \/().-]{2,45}?)(?=\s{2,}[A-Z]\s{2,}|\s*$)/
26
+
27
+ # A more permissive variant used by the RI/SDI extractor, whose values carry
28
+ # punctuation the AFSC tables never do: a colon ("8W1XX WSMs w/PAFSC: 2W0X1"
29
+ # in the Space Force 8W tables) and a comma ("Graduated, Flight Chief" in
30
+ # the 8R300 recruiter table). The AFSC ladder pipeline keeps the stricter
31
+ # PAIR (its default), so the shipped enlisted/officer artifacts are
32
+ # unaffected.
33
+ RI_SDI_PAIR = /\b([A-Z])\s{2,}([A-Z0-9][A-Za-z0-9 \/().:,-]{2,45}?)(?=\s{2,}[A-Z]\s{2,}|\s*$)/
34
+
35
+ def initialize(text, publication: Publication.dafecd, pair: PAIR)
36
+ @text = text
37
+ @header = publication.shredout_header
38
+ @pair = pair
39
+ end
40
+
41
+ # @return [Hash{Symbol=>String}] suffix letter => shredout name
42
+ def parse
43
+ result = {}
44
+ table_lines.each do |line|
45
+ # Strip stray decorative glyphs that would otherwise break the column
46
+ # lookahead (e.g. a U+F0EA bullet sitting in a between-column gap).
47
+ line.gsub(Patterns::DECORATIVE, " ").scan(@pair) do |suffix, name|
48
+ result[suffix.to_sym] = name.strip
49
+ end
50
+ end
51
+ result
52
+ end
53
+
54
+ private
55
+
56
+ # Lines belonging to the shredout table body: everything after the
57
+ # "Suffix ..." header up to a terminating boundary (a *NOTE, a numbered
58
+ # section, or the end of the given text).
59
+ def table_lines
60
+ lines = @text.lines
61
+ start = lines.index { |line| line.match?(@header) }
62
+ return [] if start.nil?
63
+
64
+ body = []
65
+ lines[(start + 1)..].each do |line|
66
+ stripped = line.strip
67
+ break if stripped.start_with?("*NOTE", "NOTE:")
68
+ break if stripped.match?(/\A\d+(?:\.\d+)*\.\s/)
69
+ body << line
70
+ end
71
+ body
72
+ end
73
+ end
74
+ end
75
+ end