From 61983ce90f7e64ebd0bbace307e8ec93820b3c82 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Thu, 9 Jul 2026 09:33:04 -0400 Subject: [PATCH 1/3] Support Ruby 4.0 Pin the toolchain to Ruby 4.0.5 and add it to the CI matrix alongside 3.3.8 and 3.4.3. Changed: Test against Ruby 4.0.5 --- .github/workflows/main.yml | 1 + .tool-versions | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e1da37c..ba0a6bb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,7 @@ jobs: ruby: - "3.3.8" - "3.4.3" + - "4.0.5" steps: - uses: actions/checkout@v6 diff --git a/.tool-versions b/.tool-versions index a72ead6..aac7389 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.4.3 +ruby 4.0.5 From 2b220f4cfbaf46a01571be6ee65540fce9aaae0e Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Thu, 9 Jul 2026 09:33:05 -0400 Subject: [PATCH 2/3] Add DAFECD extraction tooling Deterministic, offline pipeline that parses the DAFECD PDF into a verified, specialty-keyed index: record splitting, header/shredout parsing, a verification gate, and a governed title de-gluer whose output is checked to differ from the source by whitespace/case only. Requires pdf-reader (a dev dependency); never loaded by the runtime. Source PDFs are git-ignored. --- .gitignore | 4 + Gemfile | 2 + Gemfile.lock | 14 + bin/extract_afsc_from_pdf.rb | 182 ++++++++++ lib/gov_codes/dafecd/index_builder.rb | 252 +++++++++++++ lib/gov_codes/dafecd/patterns.rb | 50 +++ lib/gov_codes/dafecd/record_splitter.rb | 76 ++++ lib/gov_codes/dafecd/shredout_parser.rb | 64 ++++ lib/gov_codes/dafecd/specialty_parser.rb | 199 +++++++++++ lib/gov_codes/dafecd/text.rb | 37 ++ lib/gov_codes/dafecd/title_degluer.rb | 59 ++++ lib/gov_codes/dafecd/title_overrides.yml | 142 ++++++++ test/gov_codes/dafecd/index_builder_test.rb | 206 +++++++++++ test/gov_codes/dafecd/record_splitter_test.rb | 89 +++++ test/gov_codes/dafecd/shredout_parser_test.rb | 80 +++++ .../gov_codes/dafecd/specialty_parser_test.rb | 333 ++++++++++++++++++ test/gov_codes/dafecd/title_degluer_test.rb | 46 +++ 17 files changed, 1835 insertions(+) create mode 100755 bin/extract_afsc_from_pdf.rb create mode 100644 lib/gov_codes/dafecd/index_builder.rb create mode 100644 lib/gov_codes/dafecd/patterns.rb create mode 100644 lib/gov_codes/dafecd/record_splitter.rb create mode 100644 lib/gov_codes/dafecd/shredout_parser.rb create mode 100644 lib/gov_codes/dafecd/specialty_parser.rb create mode 100644 lib/gov_codes/dafecd/text.rb create mode 100644 lib/gov_codes/dafecd/title_degluer.rb create mode 100644 lib/gov_codes/dafecd/title_overrides.yml create mode 100644 test/gov_codes/dafecd/index_builder_test.rb create mode 100644 test/gov_codes/dafecd/record_splitter_test.rb create mode 100644 test/gov_codes/dafecd/shredout_parser_test.rb create mode 100644 test/gov_codes/dafecd/specialty_parser_test.rb create mode 100644 test/gov_codes/dafecd/title_degluer_test.rb diff --git a/.gitignore b/.gitignore index 9106b2a..1659fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ /pkg/ /spec/reports/ /tmp/ + +# Source classification directories (DAFECD/DAFOCD) are extraction inputs, +# not shipped data. Keep them out of the repo. +*.pdf diff --git a/Gemfile b/Gemfile index 2f8df77..ea18fd1 100644 --- a/Gemfile +++ b/Gemfile @@ -18,3 +18,5 @@ gem "simplecov" gem "reissue" gem "nokogiri" + +gem "pdf-reader" diff --git a/Gemfile.lock b/Gemfile.lock index 2626d1a..607bb8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,13 +6,17 @@ PATH GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) + afm (1.0.0) ast (2.4.3) + bigdecimal (3.3.1) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) docile (1.4.1) erb (6.0.1) + hashery (2.1.2) io-console (0.8.2) irb (1.16.0) pp (>= 0.6.0) @@ -31,6 +35,12 @@ GEM parser (3.3.10.1) ast (~> 2.4.1) racc + pdf-reader (2.15.1) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (>= 0.2.1, < 2) + hashery (~> 2.0) + ruby-rc4 + ttfunk pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -69,6 +79,7 @@ GEM rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -89,6 +100,8 @@ GEM rubocop-performance (~> 1.26.0) stringio (3.2.0) tsort (0.2.0) + ttfunk (1.8.0) + bigdecimal (~> 3.1) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -102,6 +115,7 @@ DEPENDENCIES irb minitest nokogiri + pdf-reader rake reissue simplecov diff --git a/bin/extract_afsc_from_pdf.rb b/bin/extract_afsc_from_pdf.rb new file mode 100755 index 0000000..6d5966c --- /dev/null +++ b/bin/extract_afsc_from_pdf.rb @@ -0,0 +1,182 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Deterministic DAFECD enlisted extractor (Phase C.1a). +# +# Reads the official Department of the Air Force Enlisted Classification +# Directory (DAFECD) PDF, builds a verified specialty-keyed index, writes the +# versioned release artifact + manifest, and prints a coverage report. +# +# Usage: +# mise exec -- ruby bin/extract_afsc_from_pdf.rb "DAFECD -31 October 25 v3.5 FINAL.pdf" +# +# This is offline dev tooling: it requires pdf-reader (a dev dependency) and is +# never loaded by the gem runtime. + +require "pdf-reader" +require "yaml" +require "fileutils" +require_relative "../lib/gov_codes/dafecd/index_builder" +require_relative "../lib/gov_codes/dafecd/text" +require_relative "../lib/gov_codes/dafecd/title_degluer" + +DIRECTORY_NAME = "Department of the Air Force Enlisted Classification Directory" +# The plan estimated ~161 enlisted specialties; the 31 Oct 25 edition actually +# contains ~136 skill-ladder AFSC specialties (career fields 1-8). Shown for +# reference only. +PLAN_ESTIMATE = 161 + +def effective_date_from(text) + raw = text[/DAFECD,\s+(\d{1,2}\s+\w{3,9}\s+\d{2,4})/, 1] + raw && GovCodes::Dafecd::Text.parse_date(raw) +end + +def version_label_from(filename) + filename[/\bv(\d+(?:\.\d+)?)/i]&.then { |m| "v#{m[/[\d.]+/]}" } +end + +def clean_entry(entry) + { + name: entry[:name], + career_field: entry[:career_field], + cem_code: entry[:cem_code], + changed_date: entry[:changed_date], + skill_levels: entry[:skill_levels].sort.to_h, + shredouts: entry[:shredouts].sort_by { |k, _| k.to_s }.to_h + }.reject { |_, v| v.nil? } +end + +pdf_file = ARGV[0] + +unless pdf_file + warn "ERROR: PDF file required" + warn "Usage: #{$PROGRAM_NAME} PDF_FILE" + exit 1 +end + +unless File.exist?(pdf_file) + warn "ERROR: File not found: #{pdf_file}" + exit 1 +end + +puts "=" * 72 +puts "DAFECD enlisted extractor (Phase C.1a)" +puts "=" * 72 +puts "Source: #{pdf_file}" + +full_text = PDF::Reader.new(pdf_file).pages.map(&:text).join("\n") +puts "Extracted #{full_text.length} characters" + +degluer = GovCodes::Dafecd::TitleDegluer.load +builder = GovCodes::Dafecd::IndexBuilder.new(full_text, degluer: degluer) +index = builder.build + +# --- Fail loudly on any verification failure BEFORE writing ---------------- +# A drifting title override (letters no longer match the source) or an +# ungrounded code must abort the build rather than emit a stale/hallucinated +# value. +if builder.unverified? + warn "\nBUILD FAILED: verification gate rejected #{builder.unverified_codes.size} code(s) " \ + "and #{builder.unverified_titles.size} title override(s)." + builder.unverified_codes.uniq.sort.each { |c| warn " ungrounded code: #{c}" } + builder.unverified_titles.each do |t| + warn " drifting title override #{t[:specialty]}: applied=#{t[:applied].inspect} " \ + "source=#{t[:raw_title].inspect} (#{t[:reason]})" + end + warn "Nothing was written. Fix the overrides (lib/gov_codes/dafecd/title_overrides.yml)." + exit 1 +end + +effective_date = effective_date_from(full_text) || "unknown" +version_label = version_label_from(File.basename(pdf_file)) + +# --- Write the release artifact ------------------------------------------- +sorted_index = index.sort_by { |k, _| k.to_s }.to_h.transform_values { |e| clean_entry(e) } + +release_dir = File.join("lib/gov_codes/afsc/releases/dafecd", effective_date) +FileUtils.mkdir_p(release_dir) +enlisted_path = File.join(release_dir, "enlisted.yml") + +header = <<~HEADER + # DAFECD enlisted AFSC index (specialty-keyed, X-form) + # Source: #{File.basename(pdf_file)} + # Directory: #{DIRECTORY_NAME} + # Effective date: #{effective_date}#{" (#{version_label})" if version_label} + # Generated deterministically by bin/extract_afsc_from_pdf.rb (Phase C.1a). + # Do not edit by hand; re-run the extractor against the source PDF. +HEADER + +File.write(enlisted_path, header + sorted_index.to_yaml) + +# --- Update the release manifest ------------------------------------------ +manifest_path = "lib/gov_codes/afsc/releases.yml" +manifest = File.exist?(manifest_path) ? (YAML.safe_load_file(manifest_path, permitted_classes: [Symbol]) || {}) : {} +manifest[:dafecd] ||= [] +manifest[:dafecd].reject! { |r| r[:effective_date] == effective_date } +manifest[:dafecd] << { + effective_date: effective_date, + version_label: version_label, + source: File.basename(pdf_file), + name: DIRECTORY_NAME +} +manifest[:dafecd].sort_by! { |r| r[:effective_date].to_s } +File.write(manifest_path, manifest.to_yaml) + +# --- Coverage report ------------------------------------------------------- +concrete_codes = index.values.flat_map { |e| e[:skill_levels].values.map { |l| l[:code] } } +cem_codes = index.values.filter_map { |e| e[:cem_code] } + +puts +puts "-" * 72 +puts "COVERAGE REPORT" +puts "-" * 72 +puts "Effective date: #{effective_date}" +puts "Version label: #{version_label || "(none found)"}" +puts "(Plan estimated ~#{PLAN_ESTIMATE} specialties; that was high. Actual counts below.)" +puts +puts "RECORD RECONCILIATION (split == parsed + merged + dropped)" +puts " Records split: #{builder.records_split}" +puts " Specialties parsed: #{index.size}" +puts " Records merged: #{builder.merged_count} (duplicate/continuation records folded into an existing specialty)" +puts " Records dropped: #{builder.dropped_records.size}" +reconciled = index.size + builder.merged_count + builder.dropped_records.size +puts " Reconciled total: #{reconciled} (#{(reconciled == builder.records_split) ? "OK" : "MISMATCH!"})" +builder.dropped_records.each do |d| + puts " dropped: cem=#{d[:cem_code].inspect} first=#{d[:first_line].inspect}" + puts " reason: #{d[:reason]}" +end +puts +puts "CODES" +puts " Skill-level codes: #{concrete_codes.size}" +puts " CEM codes: #{cem_codes.size}" +puts " Unverified codes: #{builder.unverified_codes.size}" +unless builder.unverified_codes.empty? + puts " !! #{builder.unverified_codes.uniq.sort.join(", ")}" +end +puts " NOTE: the code gate is a regression guard — every code here is a verbatim" +puts " slice of the source, so 0 unverified is guaranteed by construction." +puts +puts "TITLES (de-glued via verified overrides)" +missing_title = builder.specialties_missing_title +puts " Specialties missing title: #{missing_title.size}" +puts " #{missing_title.map(&:to_s).sort.join(", ")}" unless missing_title.empty? +puts " Needs de-gluing (no override): #{builder.specialties_needing_deglue.size}" +unless builder.specialties_needing_deglue.empty? + puts " #{builder.specialties_needing_deglue.map(&:to_s).sort.join(", ")}" +end +puts " Drifting overrides (rejected): #{builder.unverified_titles.size} (build fails if > 0)" +puts " Applied clean titles: #{index.size - builder.specialties_needing_deglue.size}" +puts " The title gate is MEANINGFUL: each applied override is verified to match" +puts " its raw source title with only spacing/case changed; drift fails the build." +no_shred = builder.specialties_without_shredouts +puts " Specialties without shredouts: #{no_shred.size} (normal for many specialties)" +puts +puts "SAMPLE DE-GLUED NAMES" +%i[1A1X2 1C3X1 1Z3X1 2A3X7 3N3X1 4J0X2].each do |spec| + puts " #{spec}: #{index[spec]&.dig(:name).inspect}" +end + +puts +puts "Wrote #{enlisted_path}" +puts "Wrote #{manifest_path}" +puts "Review the diff with 'git diff' before committing (do NOT commit the PDF)." diff --git a/lib/gov_codes/dafecd/index_builder.rb b/lib/gov_codes/dafecd/index_builder.rb new file mode 100644 index 0000000..a3f3e05 --- /dev/null +++ b/lib/gov_codes/dafecd/index_builder.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require_relative "patterns" +require_relative "record_splitter" +require_relative "specialty_parser" +require_relative "shredout_parser" +require_relative "title_degluer" + +module GovCodes + module Dafecd + # Assembles parsed DAFECD records into a specialty-keyed index. + # + # Verification gate (DEC-003) — read this before trusting "0 unverified". + # The gate confirms that every value emitted into the index appears verbatim + # in the source text. For the CURRENT purely-deterministic extraction every + # emitted code is a verbatim slice of the source, so the gate is guaranteed + # to pass — "0 unverified" is a tautology here, NOT evidence of correctness. + # The gate earns its keep as a REGRESSION GUARD for future value-transforming + # steps (notably the C.2 title de-gluer, whose invariant is "the de-glued + # title equals its source with only spaces changed"). Such a transform + # registers the values it emits via the #emitted_values_to_verify hook, and + # the gate flags any that are not grounded in the source. + # + # Drops are surfaced, never hidden: any record that carries a specialty + # signal (a CEM code) but yields zero parsed ladder codes is collected in + # #dropped_records with a reason. The invariant + # records_split == index.size + merged_count + dropped_records.size holds. + # + # Title de-gluing: a TitleDegluer supplies verified clean titles (see + # TitleDegluer). Each applied override is checked against the raw source + # title via the de-gluing invariant (spacing/case only); drift lands in + # #unverified_titles and must fail the build. Specialties without an override + # keep the auto-titlecased name and are listed in #specialties_needing_deglue. + class IndexBuilder + def initialize(source_text, degluer: TitleDegluer.empty) + @source_text = source_text + @degluer = degluer + @index = nil + @unverified_codes = nil + end + + # @return [Hash{Symbol=>Hash}] X-form specialty => index entry + def build + @index = {} + @unverified_codes = [] + @unverified_titles = [] + @needs_deglue = [] + @dropped_records = [] + @merged_count = 0 + + records = RecordSplitter.new(@source_text).records + @records_split = records.size + + records.each do |record| + header = SpecialtyParser.new(record).parse + specialty = header[:specialty] + + if specialty.nil? + @dropped_records << drop_descriptor(record) + next + end + + entry = build_entry(header, ShredoutParser.new(record).parse) + @merged_count += 1 if @index.key?(specialty) + merge_entry(specialty, entry) + end + + @index.each do |specialty, entry| + apply_override(specialty, entry) + verify(entry) + end + @index + end + + # Values emitted into the index that could NOT be found verbatim in the + # source. Empty by construction for the current deterministic extraction; + # non-empty means a transform (e.g. the de-gluer) emitted an ungrounded + # value. See the class docstring. + def unverified_codes + build if @unverified_codes.nil? + @unverified_codes + end + + # Applied title overrides that violate the de-gluing invariant (their + # letters/digits/punctuation differ from the raw source title), each a + # Hash of {specialty:, applied:, raw_title:, reason:}. Non-empty means a + # stale/incorrect override; the build must fail. See #unverified?. + def unverified_titles + build if @unverified_titles.nil? + @unverified_titles + end + + # Specialties that have an auto-titlecased name but no verified override + # (i.e. still need de-gluing). Should be empty once every specialty is + # covered; kept for robustness across future entity types. + def specialties_needing_deglue + build if @needs_deglue.nil? + @needs_deglue + end + + # True if any emitted value (code or applied title) failed verification. + # The CLI must fail the build when this is true. + def unverified? + unverified_codes.any? || unverified_titles.any? + end + + # Records that carried a specialty signal but produced no ladder codes, + # each a Hash of {cem_code:, first_line:, reason:}. Surfaced so drops can + # never hide (C1). + def dropped_records + build if @dropped_records.nil? + @dropped_records + end + + # Number of records the splitter produced. + def records_split + build if @records_split.nil? + @records_split + end + + # Number of records that merged into an already-seen specialty. + def merged_count + build if @merged_count.nil? + @merged_count + end + + # The verification-gate predicate: does +value+ appear verbatim in source? + def verified?(value) + @source_text.include?(value.to_s) + end + + # Specialties whose title could not be extracted (for the coverage report). + def specialties_missing_title + build if @index.nil? + @index.select { |_, e| e[:name].nil? || e[:name].empty? }.keys + end + + # Specialties with no shredout table (for the coverage report). This is a + # normal condition for many specialties, not an error. + def specialties_without_shredouts + build if @index.nil? + @index.select { |_, e| e[:shredouts].empty? }.keys + end + + # Specialties whose title is flagged as a probable pdf-reader glue artifact + # (heuristic), deferred to the C.2 despacer. + def glued_titles + build if @index.nil? + @index.select { |_, e| e[:glued_title] }.transform_values { |e| e[:name] } + end + + # Full title inventory for the de-gluing step: every specialty with its + # current (title-cased) name and the raw pre-titlecase source title. + # @return [Array] {specialty:, name:, raw_title:, glued:} + def title_inventory + build if @index.nil? + @index.sort_by { |k, _| k.to_s }.map do |specialty, e| + {specialty: specialty, name: e[:name], raw_title: e[:raw_title], glued: e[:glued_title]} + end + end + + private + + # Describe a dropped record for the accounting report. + def drop_descriptor(record) + cem = record[Patterns::CEM, 1] + first_line = record.lines.map(&:strip).reject(&:empty?).first + reason = + if cem + "CEM code present but no skill-ladder AFSC lines (career-field CEM manager)" + else + "no CEM code and no skill-ladder AFSC lines" + end + {cem_code: cem, first_line: first_line, reason: reason} + end + + def build_entry(header, shredouts) + { + name: header[:name], + raw_title: header[:raw_title], + career_field: header[:career_field], + cem_code: header[:cem_code], + changed_date: header[:changed_date], + skill_levels: header[:skill_levels], + shredouts: shredouts, + glued_title: header[:glued_title] + } + end + + # Merge a re-encountered specialty (e.g. a record split by a page break) + # rather than silently overwriting it. + def merge_entry(specialty, entry) + existing = @index[specialty] + if existing.nil? + @index[specialty] = entry + return + end + + existing[:name] ||= entry[:name] + existing[:raw_title] ||= entry[:raw_title] + existing[:career_field] ||= entry[:career_field] + existing[:cem_code] ||= entry[:cem_code] + existing[:changed_date] ||= entry[:changed_date] + existing[:skill_levels] = entry[:skill_levels].merge(existing[:skill_levels]) + existing[:shredouts] = entry[:shredouts].merge(existing[:shredouts]) + existing[:glued_title] ||= entry[:glued_title] + end + + # Apply a verified de-glued title override, enforcing the invariant that an + # override differs from the raw source title only in spacing/case. Drift is + # recorded (never silently applied); a missing override keeps the auto + # title and is flagged for de-gluing. + def apply_override(specialty, entry) + override = @degluer.override_for(specialty) + + if override.nil? + @needs_deglue << specialty if entry[:name] + return + end + + if TitleDegluer.matches_source?(override, entry[:raw_title]) + entry[:name] = override + else + @unverified_titles << { + specialty: specialty, + applied: override, + raw_title: entry[:raw_title], + reason: "override differs from source title by more than spacing/case" + } + end + end + + def verify(entry) + entry[:skill_levels].each_value do |level| + @unverified_codes << level[:code] unless verified?(level[:code]) + end + cem = entry[:cem_code] + @unverified_codes << cem if cem && !verified?(cem) + emitted_values_to_verify(entry).each do |value| + @unverified_codes << value unless verified?(value) + end + end + + # Hook for future value-transforming steps (e.g. the C.2 title de-gluer): + # return the additional values the transform emits into this entry so the + # gate can confirm each is grounded in the source. Default: none. + def emitted_values_to_verify(entry) + [] + end + end + end +end diff --git a/lib/gov_codes/dafecd/patterns.rb b/lib/gov_codes/dafecd/patterns.rb new file mode 100644 index 0000000..006fb1c --- /dev/null +++ b/lib/gov_codes/dafecd/patterns.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module GovCodes + module Dafecd + # Shared line-anchor patterns used by both RecordSplitter (to detect record + # boundaries) and SpecialtyParser (to extract the ladder). Kept in one place + # so the two never drift apart. + module Patterns + # Skill-ladder line. Captures the concrete 5-char AFSC (group 1) and the + # skill-level word (group 2). Tolerant of the DAFECD's formatting quirks: + # * an OPTIONAL leading "AFSC" prefix — pdf-reader sometimes shifts the + # "AFSC" token to the end of the previous line, leaving a bare code at + # line start (e.g. "1A194, SuperintendentAFSC"), + # * an optional TRAILING "AFSC" (that shifted token), + # * an optional "*" restriction marker on the code, + # * an optional alternate code, e.g. "3E471 or 3E471A", + # * an optional comma, + # * an optional specialty-specific qualifier before the level word, + # e.g. "Cryptologic Intelligence Superintendent", + # * a wrapped "Senior Enlisted" (Leader on the following line), + # * an optional trailing acronym, e.g. "Senior Enlisted Leader (SEL)". + # The code must be at line start and the level word must END the line; + # together these reject prose mentions such as "possession ofAFSC 1Z331, + # which ...". (Prefix-glue like "LeaderAFSC 1A178" is split onto its own + # line by Text.split_glued_afsc before this pattern is applied.) + LADDER = / + ^\s*(?:AFSC\s+)?(\d[A-Z]\d\d\d)\*? + (?:\s+or\s+\d[A-Z0-9]+)? + ,?\s* + (?:[A-Za-z][A-Za-z\/]*\s+){0,3} + (Helper|Apprentice|Journeyman|Craftsman| + Superintendent|Senior\s+Enlisted(?:\s+Leader)?|Entry) + (?:\s*\([A-Z]{2,6}\))? + (?:\s*AFSC)? + \s*$ + /x + + # CEM (Chief Enlisted Manager) code line, e.g. "CEM Code 1A100*". + CEM = /^\s*CEM Code\s+(\d[A-Z]\d00)\*?/ + + # Decorative glyphs pdf-reader lifts from symbol fonts and scatters through + # the text: Private Use Area bullets, black/white stars, and common + # bullets. They are never part of the data and are stripped before parsing. + DECORATIVE = /[\u{E000}-\u{F8FF}★☆•●▪■⁃∙]/ + + # Unicode dashes the directory uses, normalized to ASCII "-". + UNICODE_DASHES = /[‐-―−]/ + end + end +end diff --git a/lib/gov_codes/dafecd/record_splitter.rb b/lib/gov_codes/dafecd/record_splitter.rb new file mode 100644 index 0000000..f1262f6 --- /dev/null +++ b/lib/gov_codes/dafecd/record_splitter.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "patterns" +require_relative "text" + +module GovCodes + # Dev-only tooling that parses the official Department of the Air Force + # Enlisted Classification Directory (DAFECD) PDF text into structured data. + # + # These classes are NEVER required by the gem runtime. They are used offline + # (via bin/extract_afsc_from_pdf.rb) to regenerate the versioned index when a + # new directory is released. + module Dafecd + # Splits the full DAFECD text into one string per specialty record. + # + # A specialty record begins at either: + # * a "CEM Code " line, or + # * the first ladder line ("AFSC , ") of a ladder group + # that is NOT immediately preceded (ignoring blank lines) by another + # ladder line or a CEM Code line. + # + # Running page headers ("DAFECD, ") and the page-number lines that + # accompany them are stripped before splitting. + class RecordSplitter + # Running header emitted on every page, e.g. "DAFECD, 31 Oct 25". + HEADER = /^\s*DAFECD,\s+\d/ + + LADDER = Patterns::LADDER + CEM = Patterns::CEM + + # A lone title-case word on its own line (e.g. "Leader" left behind when + # Text.split_glued_afsc splits "LeaderAFSC 1A178"). Such wrapped + # continuations must not break a ladder group. + CONTINUATION_WORD = /\A[A-Z][a-z]+\z/ + + def initialize(text) + @text = Text.split_glued_afsc(text) + end + + # @return [Array] one string per specialty record + def records + lines = @text.lines.reject { |line| line =~ HEADER } + + records = [] + current = nil + prev_meaningful_was_ladder_or_cem = false + + lines.each do |line| + is_ladder = line =~ LADDER + is_cem = line =~ CEM + stripped = line.strip + # Blank lines and lone continuation words are neutral: they neither + # start a record nor break a run of ladder/CEM lines. + neutral = stripped.empty? || stripped.match?(CONTINUATION_WORD) + + starts_record = + is_cem || + (is_ladder && !prev_meaningful_was_ladder_or_cem) + + if starts_record + current = +"" + records << current + end + + current << line if current + + unless neutral + prev_meaningful_was_ladder_or_cem = is_ladder || is_cem + end + end + + records + end + end + end +end diff --git a/lib/gov_codes/dafecd/shredout_parser.rb b/lib/gov_codes/dafecd/shredout_parser.rb new file mode 100644 index 0000000..70f179b --- /dev/null +++ b/lib/gov_codes/dafecd/shredout_parser.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "patterns" + +module GovCodes + module Dafecd + # Parses a DAFECD "Specialty Shredouts" table into a suffix => name map. + # + # The table is laid out in two side-by-side columns, e.g.: + # + # Suffix Primary Aircraft Suffix Primary Aircraft + # A C-5 Flight Engineer L C-130H Flight Engineer + # B C-5 Loadmaster N C-130H Loadmaster + # + # Both columns are captured. Values that wrap onto a continuation line are + # captured only up to the wrap (a documented, minor limitation). + class ShredoutParser + # A single suffix/name cell. The suffix is one capital letter followed by + # 2+ spaces; the name runs until 2+ spaces precede the next column's suffix + # or the line ends. + PAIR = /\b([A-Z])\s{2,}([A-Z0-9][A-Za-z0-9 \/().-]{2,45}?)(?=\s{2,}[A-Z]\s{2,}|\s*$)/ + + # The table's column header ("Suffix Primary Aircraft Suffix ..."). + HEADER = /Suffix\s+\w/ + + def initialize(text) + @text = text + end + + # @return [Hash{Symbol=>String}] suffix letter => shredout name + def parse + result = {} + table_lines.each do |line| + # Strip stray decorative glyphs that would otherwise break the column + # lookahead (e.g. a U+F0EA bullet sitting in a between-column gap). + line.gsub(Patterns::DECORATIVE, " ").scan(PAIR) do |suffix, name| + result[suffix.to_sym] = name.strip + end + end + result + end + + private + + # Lines belonging to the shredout table body: everything after the + # "Suffix ..." header up to a terminating boundary (a *NOTE, a numbered + # section, or the end of the given text). + def table_lines + lines = @text.lines + start = lines.index { |line| line.match?(HEADER) } + return [] if start.nil? + + body = [] + lines[(start + 1)..].each do |line| + stripped = line.strip + break if stripped.start_with?("*NOTE", "NOTE:") + break if stripped.match?(/\A\d+(?:\.\d+)*\.\s/) + body << line + end + body + end + end + end +end diff --git a/lib/gov_codes/dafecd/specialty_parser.rb b/lib/gov_codes/dafecd/specialty_parser.rb new file mode 100644 index 0000000..1cbc060 --- /dev/null +++ b/lib/gov_codes/dafecd/specialty_parser.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require_relative "patterns" +require_relative "text" + +module GovCodes + module Dafecd + # Parses a single DAFECD specialty record (as produced by RecordSplitter) + # into a structured header: X-form specialty, career field, CEM code, + # per-specialty change date, skill ladder, and the (conservatively + # normalized) specialty title. + # + # Deliberately does NOT attempt to de-glue titles that pdf-reader ran + # together (e.g. "FORCEAVIATOR"). Such titles are title-cased verbatim and + # flagged via :glued_title so the deferred C.2 LLM despacer can address them. + class SpecialtyParser + # Ladder line (captures concrete AFSC as group 1, skill-level word as + # group 2) and CEM line, shared with RecordSplitter via Patterns. + LADDER = Patterns::LADDER + CEM = Patterns::CEM + + CHANGE_DATE = /\((?:Changed|Established|Effective)\s+(\d{1,2}\s+\w{3,9}\s+\d{2,4})\)/ + + DECORATIVE = Patterns::DECORATIVE + UNICODE_DASHES = Patterns::UNICODE_DASHES + + def initialize(record) + @record = Text.split_glued_afsc(record) + @lines = @record.lines + end + + def parse + codes = ladder_codes + { + specialty: x_form(codes), + career_field: career_field(codes), + cem_code: cem_code, + changed_date: changed_date, + skill_levels: skill_levels, + name: name, + raw_title: raw_title, + glued_title: glued_title? + } + end + + private + + # @return [Array] concrete ladder AFSCs in document order + def ladder_codes + @lines.filter_map { |line| line[LADDER, 1] } + end + + def x_form(codes) + basis = specialty_basis(codes) + return nil if basis.empty? + :"#{basis.first[0, 3]}X#{most_common_specific(basis)}" + end + + def career_field(codes) + basis = specialty_basis(codes) + return nil if basis.empty? + :"#{basis.first[0, 2]}" + end + + # The ladder codes that define the specialty. A subdivision superintendent + # often carries specific digit 0 (e.g. 4J090 atop the 4J0X2 ladder), so the + # specialty is defined by its non-superintendent (skill digit != 9) levels; + # fall back to all codes when only a superintendent is present. + def specialty_basis(codes) + non_super = codes.reject { |code| code[3] == "9" } + non_super.empty? ? codes : non_super + end + + # The most frequent specific (5th) digit among the basis codes. + def most_common_specific(basis) + basis.map { |code| code[4] }.group_by(&:itself).max_by { |_, v| v.size }.first + end + + def cem_code + @record[CEM, 1] + end + + def changed_date + raw = @record[CHANGE_DATE, 1] + return nil unless raw + Text.parse_date(raw) + end + + # @return [Hash{Integer=>Hash}] skill-level digit => {code:, title:} + def skill_levels + levels = {} + @lines.each do |line| + next unless line =~ LADDER + code = $1 + title = normalize_level($2) + digit = code[3].to_i + levels[digit] = {code: code, title: title} + end + levels + end + + def normalize_level(word) + collapsed = word.gsub(/\s+/, " ").strip + (collapsed == "Senior Enlisted") ? "Senior Enlisted Leader" : collapsed + end + + def name + raw = raw_title + return nil if raw.nil? || raw.empty? + titleize(raw) + end + + def glued_title? + title = name + return false if title.nil? + # Heuristic proxy for pdf-reader glue: an unusually long single token + # (excluding preserved acronyms). Reported, not relied upon, for the + # deferred C.2 despacer. False positives are possible for genuinely + # long words; the coverage report lists them for human review. + title.split(/\s+/).any? do |token| + bare = token.delete("()/-") + bare.length >= 12 && bare.match?(/\A[A-Za-z]+\z/) + end + end + + # The title line(s) between the last ladder line and the change date / + # first numbered section, joined and sanitized (pre-titlecase). Accepts + # both the common ALL-CAPS titles and the Title-Case titles used by a few + # special-duty specialties (e.g. "Multi-domain Operations Aviator"). + # Exposed verbatim so the C.2 de-gluer can diff against the source. + def raw_title + return @raw_title if defined?(@raw_title) + @raw_title = compute_raw_title + end + + def compute_raw_title + last_ladder = last_ladder_index + return nil if last_ladder.nil? + + collected = [] + @lines[(last_ladder + 1)..].each do |line| + stripped = sanitize(line) + next if stripped.empty? + next if stripped.match?(/\A\d+\z/) # bare page number + next if stripped.match?(/\A[A-Z][a-z]+\z/) # wrapped ladder word (e.g. "Leader") + break if stripped.match?(/\A\(?(?:Changed|Established|Effective)\b/) # change date + break if stripped.match?(/\A\d+\./) # numbered section (1. / 1.Specialty / 3.4.1) + break if stripped.match?(/\b(?:Specialty Summary|Special Duty Summary|Duties and Responsibilities)\b/) + + if title_line?(stripped) + collected << stripped + else + break + end + end + + return nil if collected.empty? + collected.join(" ") + end + + def last_ladder_index + @lines.each_index.reverse_each.find { |i| @lines[i] =~ LADDER } + end + + # Remove decorative symbol glyphs, normalize unicode dashes, and collapse + # runs of whitespace (pdf-reader pads some titles with long space runs) so + # a title line reduces to its plain-text content. + def sanitize(line) + line.gsub(DECORATIVE, "").gsub(UNICODE_DASHES, "-").gsub(/\s+/, " ").strip + end + + # A title line is short, starts with a capital / paren / digit, and is not + # a prose sentence. Accepts ALL-CAPS and Title-Case forms; the boundary + # breaks in compute_raw_title stop collection before the summary prose. + def title_line?(stripped) + return false if stripped.length > 70 + return false unless stripped.match?(/\A[A-Z0-9(]/) + return false if stripped.split(/\s+/).size > 10 + return false if stripped.match?(/[a-z]\.\s+[A-Z]/) # mid-line sentence break = prose + stripped.match?(/[A-Za-z]/) + end + + def titleize(raw) + raw.split(/\s+/).map { |word| titleize_word(word) }.join(" ") + end + + def titleize_word(word) + # Preserve parenthesized acronyms, e.g. "(RPA)", "(ISR)", "(C2)". + return word if word.match?(/\A\([A-Z0-9&\/.-]{2,}\)\z/) + # Preserve all-caps tokens containing a digit, e.g. "C2", "F16". + return word if word.match?(/\A[A-Z]*\d[A-Z0-9]*\z/) && word.match?(/[A-Z0-9]/) + + word.split(/([\/-])/).map { |seg| + seg.match?(/[A-Za-z]/) ? seg.capitalize : seg + }.join + end + end + end +end diff --git a/lib/gov_codes/dafecd/text.rb b/lib/gov_codes/dafecd/text.rb new file mode 100644 index 0000000..0c9a2a1 --- /dev/null +++ b/lib/gov_codes/dafecd/text.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module GovCodes + module Dafecd + # Shared text utilities for the DAFECD extractor: normalization of + # pdf-reader glue artifacts and date parsing. Used by RecordSplitter, + # SpecialtyParser, and the extractor CLI so the behavior stays consistent. + module Text + MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec] + .each_with_index.to_h { |m, i| [m, i + 1] }.freeze + + module_function + + # pdf-reader frequently glues the word "AFSC" to a preceding word when a + # ladder line wraps, e.g. "LeaderAFSC 1A178*, Craftsman". Insert a newline + # before an "AFSC " that is glued to a letter so the ladder line is + # detectable at line start. This is safe for prose mentions such as + # "possession ofAFSC 1A132" because ladder detection additionally requires + # a skill-level word at end of line, which prose lines do not satisfy. + def split_glued_afsc(text) + text.gsub(/(?<=[A-Za-z])(?=AFSC \d[A-Z]\d\d\d)/, "\n") + end + + # Normalize a DAFECD date such as "31 Oct 25" or "31 Oct 2024" to ISO 8601 + # ("2025-10-31" / "2024-10-31"). Returns nil when unparseable. + def parse_date(raw) + return nil unless raw =~ /(\d{1,2})\s+(\w{3,9})\s+(\d{2,4})/ + day = $1.to_i + month = MONTHS[$2[0, 3].downcase] + year = $3.to_i + year += 2000 if year < 100 + return nil unless month + format("%04d-%02d-%02d", year, month, day) + end + end + end +end diff --git a/lib/gov_codes/dafecd/title_degluer.rb b/lib/gov_codes/dafecd/title_degluer.rb new file mode 100644 index 0000000..388205d --- /dev/null +++ b/lib/gov_codes/dafecd/title_degluer.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "yaml" + +module GovCodes + module Dafecd + # Applies verified de-glued specialty titles. + # + # pdf-reader drops spaces inside DAFECD titles ("MOBILITY FORCEAVIATOR"). + # The deterministic extractor title-cases those verbatim ("Mobility + # Forceaviator") and cannot safely re-insert the missing spaces. The clean + # titles are produced out of band and shipped in title_overrides.yml. + # + # The de-gluing invariant — enforced at build time by IndexBuilder — is that + # an override may only change SPACING and CASE relative to the raw source + # title; no letter, digit, or punctuation may change. Any drift (e.g. a + # future release renamed the specialty) fails the build loudly rather than + # silently applying a stale title. + class TitleDegluer + DEFAULT_PATH = File.expand_path("title_overrides.yml", __dir__) + + # @return [TitleDegluer] loaded from the shipped overrides file + def self.load(path = DEFAULT_PATH) + overrides = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {} + new(overrides) + end + + # An empty de-gluer applies no overrides (keeps the auto-titlecased names). + def self.empty + new({}) + end + + # Whitespace-and-case-insensitive normalization used for the invariant. + def self.norm(str) + str.to_s.gsub(/\s+/, "").downcase + end + + # Does +override+ differ from +raw_title+ only in spacing/case? + def self.matches_source?(override, raw_title) + return false if raw_title.nil? + norm(override) == norm(raw_title) + end + + # @param overrides [Hash{Symbol=>String}] specialty => clean title + def initialize(overrides = {}) + @overrides = overrides + end + + # @return [String, nil] the clean title for +specialty+, or nil + def override_for(specialty) + @overrides[specialty] + end + + def any? + !@overrides.empty? + end + end + end +end diff --git a/lib/gov_codes/dafecd/title_overrides.yml b/lib/gov_codes/dafecd/title_overrides.yml new file mode 100644 index 0000000..9c0028d --- /dev/null +++ b/lib/gov_codes/dafecd/title_overrides.yml @@ -0,0 +1,142 @@ +# De-glued specialty titles for DAFECD, effective 2025-10-31. +# Each value is the source PDF title with ONLY spacing/case adjusted -- +# no letters, digits, or punctuation changed. Enforced at build time: +# the IndexBuilder verifies each override against the raw source title +# (whitespace-and-case-insensitive equality) and fails the build on drift. +# Produced as the governed de-gluing step for pdf-reader space loss. +:1A1X2: Mobility Force Aviator +:1A1X3: Special Mission Aviator +:1A1X4: Multi-Domain Operations Aviator +:1A1X8: Executive Mission Aviator +:1A8X0: SEL Airborne Intelligence, Surveillance and Reconnaissance (ISR) +:1A8X1: Airborne Cryptologic Language Analyst (ACLA) +:1A8X2: Airborne Intelligence, Surveillance, and Reconnaissance (ISR) Operator +:1B4X1: Cyber Warfare Operations +:1C0X2: Aviation Resource Management +:1C1X1: Air Traffic Control +:1C3X1: All-Domain Command and Control Operations +:1C5X1: Battle Management Operations +:1C6X1: Space Systems Operations +:1C7X1: Airfield Management +:1C8X3: Radar, Airfield & Weather Systems (RAWS) +:1D7X1: Warfighter Communications +:1D7X2: Radio Frequency Transmissions and Electromagnetic Activities (EMA) +:1D7X3: Cable and Antenna +:1D7X4: Data Engineering +:1D7X5: Cybersecurity +:1H0X1: Aerospace Physiology +:1N0X1: All Source Intelligence Analyst +:1N0X2: Intelligence Superintendent +:1N1X1: Geospatial Intelligence (GEOINT) +:1N2X1: Signals Intelligence +:1N2X2: Cryptologic Intelligence Superintendent +:1N3X1: Cryptologic Language Analyst +:1N4X1: Cyber Intelligence +:1N4X2: Cryptologic Analyst & Reporter +:1N7X1: Human Intelligence Specialist +:1N8X1: Targeting Analyst +:1P0X1: Aircrew Flight Equipment +:1S0X1: Safety +:1T0X1: Survival, Evasion, Resistance, Escape (SERE) Specialist +:1U1X1: Remotely Piloted Aircraft (RPA) Pilot +:1W0X1: Weather +:1Z1X1: Pararescue +:1Z2X1: Combat Control +:1Z3X1: Tactical Air Control Party (TACP) +:1Z4X1: Special Reconnaissance +:2A0X0: Avionics +:2A0X1: Avionics Test Station, Components, and Electronic Warfare Systems +:2A2X1: MHU-139 Electrical, Environmental and Avionics Technician +:2A3X0: Fighter Aircraft Maintenance +:2A3X3: Tactical Aircraft Maintenance +:2A3X4: Fighter Aircraft Integrated Avionics +:2A3X5: Advanced Fighter Aircraft Integrated Avionics +:2A3X7: Tactical Aircraft Maintenance (5th Generation) +:2A5X0: Airlift/Special Mission Aircraft Maintenance Superintendent +:2A5X1: Airlift/Special Mission Aircraft Maintenance +:2A5X2: Helicopter/Tiltrotor Aircraft Maintenance +:2A5X4: Refuel/Bomber Aircraft Maintenance +:2A6X0: Aircraft Accessories +:2A6X1: Aerospace Propulsion +:2A6X2: Aerospace Ground Equipment +:2A6X3: Aircrew Egress Systems +:2A6X4: Aircraft Fuel Systems +:2A6X5: Aircraft Hydraulic Systems +:2A6X6: Aircraft Electrical and Environmental Systems +:2A7X0: Aircraft Fabrication +:2A7X1: Aircraft Metals Technology +:2A7X2: Nondestructive Inspection +:2A7X3: Aircraft Structural Maintenance +:2A9X4: Heavy Aircraft Integrated Avionics +:2F0X1: Fuels +:2G0X1: Logistics Plans +:2M0X0: Missile and Space Systems Maintenance +:2M0X1: Missile and Space Systems Electronic Maintenance +:2M0X2: Missile and Space Systems Maintenance +:2M0X3: Missile and Space Facilities +:2P0X1: Precision Measurement Equipment Laboratory +:2R2X1: Maintenance Management +:2S0X1: Materiel Management +:2T0X1: Traffic Management Operations +:2T1X1: Ground Transportation +:2T2X1: Air Transportation +:2T3X0: Vehicle Management +:2T3X1: Mission Generation Vehicular Equipment Maintenance +:2T3X7: Fleet Management and Analysis +:2W0X1: Munitions Systems +:2W1X1: Aircraft Armament Systems +:2W2X1: Nuclear Weapons +:3E0X0: Facility Systems +:3E0X1: Electrical Systems +:3E0X2: Electrical Power Production +:3E1X1: Heating, Ventilation, Air Conditioning, and Refrigeration +:3E2X0: Heavy Repair +:3E2X1: Pavements and Construction Equipment +:3E3X1: Structural +:3E4X0: Infrastructure Systems +:3E4X1: Water and Fuel Systems Maintenance +:3E4X3: Pest Management +:3E5X1: Engineering +:3E6X1: Operations Management +:3E7X1: Fire Protection +:3E8X1: Explosive Ordnance Disposal +:3E9X1: Emergency Management +:3F0X1: Human Resources and Administration +:3F1X1: Services +:3F2X1: Education and Training +:3F3X1: Manpower +:3F4X1: Equal Opportunity +:3G0X1: Talent Acquisition +:3H0X1: Historian +:3N0X0: Public Affairs +:3N0X6: Public Affairs +:3N1X1: Regional Band +:3N2X1: Premier Band - The USAF Band +:3N3X1: Premier Band - The USAF Academy Band +:3P0X1: Security Forces +:4A0X1: Health Services Management +:4A1X1: Medical Materiel +:4A2X1: Biomedical Equipment +:4B0X1: Bioenvironmental Engineering (BE) +:4C0X1: Mental Health Service +:4D0X1: Diet Therapy +:4E0X1: Public Health +:4H0X1: Respiratory Care Practitioner +:4J0X2: Physical Medicine +:4N0X1: Aerospace Medical Service +:4N1X1: Surgical Technologist +:4P0X1: Pharmacy +:4R0X1: Diagnostic Imaging +:4T0X0: Medical Laboratory +:4T0X1: Medical Laboratory +:4T0X2: Histopathology +:4V0X1: Ophthalmic +:4Y0X0: Dental +:4Y0X1: Dental Assistant +:4Y0X2: Dental Laboratory +:5J0X1: Paralegal +:5R0X1: Religious Affairs +:6C0X1: Contracting +:6F0X1: Financial Management and Comptroller +:7S0X1: Special Investigations +:8G0X1: Premier Honor Guard diff --git a/test/gov_codes/dafecd/index_builder_test.rb b/test/gov_codes/dafecd/index_builder_test.rb new file mode 100644 index 0000000..15e5a52 --- /dev/null +++ b/test/gov_codes/dafecd/index_builder_test.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/autorun" +require "gov_codes/dafecd/index_builder" + +# Simulates a future value-transforming step (the C.2 title de-gluer) that emits +# a value not present in the source, so the gate's regression-guard behavior can +# be exercised through #build. +class TransformingBuilder < GovCodes::Dafecd::IndexBuilder + private + + def emitted_values_to_verify(entry) + return [] unless entry[:name] + ["FABRICATED-#{entry[:name]}-NOT-IN-SOURCE"] + end +end + +describe GovCodes::Dafecd::IndexBuilder do + # Inline "full text" combining two real specialty records: 1A1X2 (glued title, + # with a shredout table) and 1B4X1 (cleanly spaced title). + let(:full_text) { + <<~TXT + DAFECD, 31 Oct 25 + CEM Code 1A100* + AFSC 1A192*, Senior Enlisted Leader + AFSC 1A172*, Craftsman + AFSC 1A152*, Journeyman + AFSC 1A132*,Apprentice + AFSC 1A112, Helper + MOBILITY FORCEAVIATOR + (Changed 31 Oct 25) + + 1. Specialty Summary. Does mobility things. + + 4. *Specialty Shredouts: + Suffix Primary Aircraft Suffix Primary Aircraft + + A C-5 Flight Engineer L C-130H Flight Engineer + B C-5 Loadmaster N C-130H Loadmaster + Y General + *NOTE: Y- General shred will be utilized with CFM oversight and approval. + + DAFECD, 31 Oct 25 + CEM Code 1B000 + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + + 1. Specialty Summary. Does cyber things. + TXT + } + + it "keys the index by X-form specialty" do + index = GovCodes::Dafecd::IndexBuilder.new(full_text).build + _(index.keys).must_include :"1A1X2" + _(index.keys).must_include :"1B4X1" + end + + it "assembles the header fields" do + index = GovCodes::Dafecd::IndexBuilder.new(full_text).build + _(index[:"1A1X2"][:career_field]).must_equal :"1A" + _(index[:"1A1X2"][:cem_code]).must_equal "1A100" + _(index[:"1A1X2"][:changed_date]).must_equal "2025-10-31" + _(index[:"1A1X2"][:skill_levels][7][:code]).must_equal "1A172" + end + + # DEVIATION FROM PLAN: the plan asserts "Mobility Force Aviator"; the glued + # source ("FORCEAVIATOR") is emitted conservatively (see SpecialtyParser). + it "emits the conservatively normalized glued title" do + index = GovCodes::Dafecd::IndexBuilder.new(full_text).build + _(index[:"1A1X2"][:name]).must_equal "Mobility Forceaviator" + end + + it "emits a cleanly spaced title unchanged" do + index = GovCodes::Dafecd::IndexBuilder.new(full_text).build + _(index[:"1B4X1"][:name]).must_equal "Cyber Warfare Operations" + end + + it "attaches the shredout map" do + index = GovCodes::Dafecd::IndexBuilder.new(full_text).build + _(index[:"1A1X2"][:shredouts][:Y]).must_equal "General" + _(index[:"1A1X2"][:shredouts][:L]).must_equal "C-130H Flight Engineer" + end + + it "reports no unverified codes for a grounded source" do + _(GovCodes::Dafecd::IndexBuilder.new(full_text).unverified_codes).must_be_empty + end + + it "verifies a code that appears verbatim in the source" do + builder = GovCodes::Dafecd::IndexBuilder.new(full_text) + _(builder.verified?("1A172")).must_equal true + _(builder.verified?("1A100")).must_equal true + end + + it "rejects a code absent from the source (anti-hallucination gate)" do + builder = GovCodes::Dafecd::IndexBuilder.new(full_text) + _(builder.verified?("9Z999")).must_equal false + end + + # The gate is a regression guard for future value-transforming steps (the C.2 + # title de-gluer). Transforms register the values they emit via the + # #emitted_values_to_verify hook; the gate flags any not grounded in the + # source. TransformingBuilder (top of file) simulates such a transform. + it "build collects an ungrounded transform value into unverified_codes" do + builder = TransformingBuilder.new(full_text) + builder.build + _(builder.unverified_codes).wont_be_empty + _(builder.unverified_codes).must_include "FABRICATED-Cyber Warfare Operations-NOT-IN-SOURCE" + end + + # C1: a record with a CEM but no skill-ladder line (a career-field CEM manager) + # must be surfaced as dropped, never silently discarded. + let(:cem_only_text) { + <<~TXT + CEM Code 1N000 + INTELLIGENCE + (Changed 31 Oct 21) + 1. Specialty Summary. Leads intelligence. + + CEM Code 1B000 + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + 1. Specialty Summary. + TXT + } + + it "surfaces a CEM-only record as dropped instead of silently discarding it" do + builder = GovCodes::Dafecd::IndexBuilder.new(cem_only_text) + index = builder.build + _(index.keys).must_equal [:"1B4X1"] + _(builder.dropped_records.size).must_equal 1 + _(builder.dropped_records.first[:cem_code]).must_equal "1N000" + _(builder.dropped_records.first[:reason]).must_match(/no.*ladder/i) + end + + it "reconciles: records split == parsed + merged + dropped" do + builder = GovCodes::Dafecd::IndexBuilder.new(cem_only_text) + index = builder.build + _(builder.records_split).must_equal(index.size + builder.merged_count + builder.dropped_records.size) + end + + it "counts a re-encountered specialty as merged" do + text = <<~TXT + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + 1. Specialty Summary. + + AFSC 1B471, Craftsman + AFSC 1B451, Journeyman + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + 2. Duties. + TXT + builder = GovCodes::Dafecd::IndexBuilder.new(text) + index = builder.build + _(index.size).must_equal 1 + _(builder.merged_count).must_equal 1 + _(builder.records_split).must_equal(index.size + builder.merged_count + builder.dropped_records.size) + end + + it "still rejects a code absent from the source via the predicate" do + gate = GovCodes::Dafecd::IndexBuilder.new("no codes here") + _(gate.verified?("1Z351")).must_equal false + end + + # --- Title de-gluing (verified overrides) -------------------------------- + + it "applies a matching title override and keeps the gate clean" do + degluer = GovCodes::Dafecd::TitleDegluer.new("1A1X2": "Mobility Force Aviator") + builder = GovCodes::Dafecd::IndexBuilder.new(full_text, degluer: degluer) + index = builder.build + _(index[:"1A1X2"][:name]).must_equal "Mobility Force Aviator" + _(builder.unverified_titles).must_be_empty + _(builder.unverified_codes).must_be_empty + _(builder.unverified?).must_equal false + end + + it "flags a drifting override that changed letters and refuses to apply it" do + degluer = GovCodes::Dafecd::TitleDegluer.new("1A1X2": "Mobility Naval Aviator") + builder = GovCodes::Dafecd::IndexBuilder.new(full_text, degluer: degluer) + index = builder.build + _(builder.unverified_titles).wont_be_empty + _(builder.unverified_titles.first[:specialty]).must_equal :"1A1X2" + _(builder.unverified?).must_equal true + # The stale override is NOT silently applied; the auto title is retained. + _(index[:"1A1X2"][:name]).must_equal "Mobility Forceaviator" + end + + it "flags a drifting override that added a stray letter" do + degluer = GovCodes::Dafecd::TitleDegluer.new("1A1X2": "Mobility Force Aviatorr") + builder = GovCodes::Dafecd::IndexBuilder.new(full_text, degluer: degluer) + builder.build + _(builder.unverified_titles).wont_be_empty + end + + it "retains the auto-titlecased name and flags specialties needing de-gluing" do + builder = GovCodes::Dafecd::IndexBuilder.new(full_text) # default: no overrides + index = builder.build + _(index[:"1A1X2"][:name]).must_equal "Mobility Forceaviator" + _(builder.specialties_needing_deglue).must_include :"1A1X2" + _(builder.specialties_needing_deglue).must_include :"1B4X1" + end +end diff --git a/test/gov_codes/dafecd/record_splitter_test.rb b/test/gov_codes/dafecd/record_splitter_test.rb new file mode 100644 index 0000000..b603707 --- /dev/null +++ b/test/gov_codes/dafecd/record_splitter_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/autorun" +require "gov_codes/dafecd/record_splitter" + +describe GovCodes::Dafecd::RecordSplitter do + let(:fixture) { + <<~TXT + DAFECD, 31 Oct 25 + CEM Code 1A100* + AFSC 1A172*, Craftsman + AFSC 1A152*, Journeyman + MOBILITY FORCEAVIATOR + (Changed 31 Oct 25) + 1. Specialty Summary. Does mobility things. + DAFECD, 31 Oct 25 + CEM Code 1B000 + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + 1. Specialty Summary. Does cyber things. + TXT + } + + it "splits into one record per specialty" do + records = GovCodes::Dafecd::RecordSplitter.new(fixture).records + _(records.size).must_equal 2 + _(records[0]).must_match(/MOBILITY FORCE/) + _(records[1]).must_match(/CYBER WARFARE/) + end + + it "strips running page headers" do + records = GovCodes::Dafecd::RecordSplitter.new(fixture).records + _(records[0]).wont_match(/DAFECD, 31 Oct 25/) + end + + it "detects a suffix-glued ladder line (bare code, trailing AFSC)" do + text = <<~TXT + CEM Code 1A100 + 1A194, SuperintendentAFSC + 1A174, CraftsmanAFSC + 1A114, Helper + MULTI-DOMAIN OPERATIONS AVIATOR + (Established 31 Oct 2024) + 1. Specialty Summary. Does things. + TXT + records = GovCodes::Dafecd::RecordSplitter.new(text).records + _(records.size).must_equal 1 + _(records[0]).must_match(/1A194/) + _(records[0]).must_match(/MULTI-DOMAIN/) + end + + it "detects a prefix-glued ladder line (Word glued to AFSC)" do + text = <<~TXT + CEM Code 1A100 + AFSC 1A198*, Senior Enlisted + LeaderAFSC 1A178*, Craftsman + AFSC 1A118, Helper + EXECUTIVE MISSION AVIATOR + (Changed 31 Oct 25) + 1. Specialty Summary. Does things. + TXT + records = GovCodes::Dafecd::RecordSplitter.new(text).records + _(records.size).must_equal 1 + _(records[0]).must_match(/AFSC 1A178/) + end + + it "starts a new record at a ladder group not preceded by a CEM line" do + text = <<~TXT + CEM Code 1D700 + AFSC 1D791, Superintendent + CYBERSPACE OPERATIONS + (Changed 31 Oct 25) + 1. Specialty Summary. Leads things. + AFSC 1D771, Craftsman + AFSC 1D751, Journeyman + CYBER DEFENSE OPERATIONS + (Changed 31 Oct 25) + 1. Specialty Summary. Defends things. + TXT + + records = GovCodes::Dafecd::RecordSplitter.new(text).records + _(records.size).must_equal 2 + _(records[0]).must_match(/CYBERSPACE OPERATIONS/) + _(records[1]).must_match(/CYBER DEFENSE OPERATIONS/) + _(records[1]).must_match(/1D771/) + end +end diff --git a/test/gov_codes/dafecd/shredout_parser_test.rb b/test/gov_codes/dafecd/shredout_parser_test.rb new file mode 100644 index 0000000..220f2af --- /dev/null +++ b/test/gov_codes/dafecd/shredout_parser_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/autorun" +require "gov_codes/dafecd/shredout_parser" + +describe GovCodes::Dafecd::ShredoutParser do + # Real two-column shredout table from the 31 Oct 25 DAFECD (1A1X2). + let(:table) { + <<~TXT + 4. *Specialty Shredouts: + Suffix Primary Aircraft Suffix Primary Aircraft + + A C-5 Flight Engineer L C-130H Flight Engineer + B C-5 Loadmaster N C-130H Loadmaster + + C C-17 Loadmaster O EC-130H Flight Engineer + D C-130J Loadmaster P LC-130H Loadmaster + + E WC-130 Loadmaster Q LC-130H Flight Engineer + F E-3 Flight Engineer Y General + + G KC-46 Boom Operator Z Data Mask Mobility Force + Aviator + H KC-135 Boom Operator + *NOTE: Y- General shred will be utilized with CFM oversight and approval. + TXT + } + + it "parses left-column suffix/name pairs" do + result = GovCodes::Dafecd::ShredoutParser.new(table).parse + _(result[:A]).must_equal "C-5 Flight Engineer" + _(result[:B]).must_equal "C-5 Loadmaster" + _(result[:G]).must_equal "KC-46 Boom Operator" + _(result[:Y]).must_equal "General" + end + + it "parses right-column suffix/name pairs from the interleave" do + result = GovCodes::Dafecd::ShredoutParser.new(table).parse + _(result[:L]).must_equal "C-130H Flight Engineer" + _(result[:N]).must_equal "C-130H Loadmaster" + _(result[:O]).must_equal "EC-130H Flight Engineer" + _(result[:Q]).must_equal "LC-130H Flight Engineer" + end + + it "captures both columns of the A…L, B…N interleave" do + result = GovCodes::Dafecd::ShredoutParser.new(table).parse + _(result.keys).must_include :A + _(result.keys).must_include :L + _(result.keys).must_include :B + _(result.keys).must_include :N + end + + it "does not emit spurious suffixes from the note line" do + result = GovCodes::Dafecd::ShredoutParser.new(table).parse + # "*NOTE: Y- General shred..." must not overwrite the real Y or add junk. + _(result[:Y]).must_equal "General" + _(result.size).must_equal 15 + end + + it "ignores decorative symbol glyphs embedded between columns" do + # In the real 1A1X2 table a Private Use Area glyph (U+F0EA) sits in the gap + # after "E-3 Flight Engineer", which otherwise breaks the column lookahead + # and drops the F suffix. + pua = "\u{F0EA}" + tbl = <<~TXT + Suffix Primary Aircraft Suffix Primary Aircraft + E WC-130 Loadmaster Q LC-130H Flight Engineer + F E-3 Flight Engineer #{pua} Y General + TXT + result = GovCodes::Dafecd::ShredoutParser.new(tbl).parse + _(result[:F]).must_equal "E-3 Flight Engineer" + _(result[:Y]).must_equal "General" + end + + it "returns an empty hash when there is no shredout table" do + result = GovCodes::Dafecd::ShredoutParser.new("1. Specialty Summary. Nothing here.").parse + _(result).must_be_empty + end +end diff --git a/test/gov_codes/dafecd/specialty_parser_test.rb b/test/gov_codes/dafecd/specialty_parser_test.rb new file mode 100644 index 0000000..58d7a68 --- /dev/null +++ b/test/gov_codes/dafecd/specialty_parser_test.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/autorun" +require "gov_codes/dafecd/specialty_parser" + +describe GovCodes::Dafecd::SpecialtyParser do + # Real snippet from the 31 Oct 25 DAFECD (1A1X2, Mobility Force Aviator). + # The title arrives glued from pdf-reader ("FORCEAVIATOR"); see the clean-title + # and glue-flag tests below for how that is handled conservatively. + let(:mobility_record) { + <<~TXT + CEM Code 1A100* + AFSC 1A192*, Senior Enlisted Leader + AFSC 1A172*, Craftsman + AFSC 1A152*, Journeyman + + AFSC 1A132*,Apprentice + AFSC 1A112, Helper + MOBILITY FORCEAVIATOR + (Changed 31 Oct 25) + + 1. Specialty Summary. The Lead-MAJCOM for aircraft and mission set will + determine the Mobility ForceAviator (MFA) performance tasks. + TXT + } + + it "parses the X-form specialty and career field from the ladder" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:specialty]).must_equal :"1A1X2" + _(result[:career_field]).must_equal :"1A" + end + + it "parses the CEM code" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:cem_code]).must_equal "1A100" + end + + it "parses the per-specialty change date as ISO 8601" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:changed_date]).must_equal "2025-10-31" + end + + it "parses the skill ladder keyed by skill-level digit" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:skill_levels][9]).must_equal({code: "1A192", title: "Senior Enlisted Leader"}) + _(result[:skill_levels][7]).must_equal({code: "1A172", title: "Craftsman"}) + _(result[:skill_levels][5]).must_equal({code: "1A152", title: "Journeyman"}) + _(result[:skill_levels][3]).must_equal({code: "1A132", title: "Apprentice"}) + _(result[:skill_levels][1]).must_equal({code: "1A112", title: "Helper"}) + end + + # DEVIATION FROM PLAN: the plan's Task 2 asserts name == "Mobility Force Aviator". + # pdf-reader glues "FORCE" and "AVIATOR" into "FORCEAVIATOR" with no recoverable + # boundary. Per the title-de-gluing judgment call, we do NOT invent a split; we + # emit the conservatively title-cased verbatim title and flag it glued for the + # deferred C.2 despacer. + it "title-cases a glued title verbatim without inventing a split" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:name]).must_equal "Mobility Forceaviator" + _(result[:glued_title]).must_equal true + end + + # Real snippet from the 31 Oct 25 DAFECD (1B4X1). Its title arrives cleanly + # spaced, so conservative title-casing yields the correct name. + let(:cyber_record) { + <<~TXT + CEM Code 1B000 + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + + 1. Specialty Summary. + TXT + } + + it "title-cases a cleanly spaced title and does not flag it glued" do + result = GovCodes::Dafecd::SpecialtyParser.new(cyber_record).parse + _(result[:name]).must_equal "Cyber Warfare Operations" + _(result[:glued_title]).must_equal false + _(result[:changed_date]).must_equal "2024-04-30" + end + + # pdf-reader prepends decorative symbol-font glyphs to many titles: a Private + # Use Area bullet (U+F0EA) and a black star (U+2605). They are not part of the + # title and must be dropped before extraction. + it "strips a leading Private Use Area glyph from the title" do + pua = "\u{F0EA}" + record = <<~TXT + CEM Code 1A100* + AFSC 1A112, Helper + #{pua}MOBILITY FORCEAVIATOR + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Mobility Forceaviator" + end + + it "strips a leading black-star marker from the title" do + star = "★" + record = <<~TXT + CEM Code 3P000 + AFSC 3P011, Helper + #{star}SECURITY FORCES + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Security Forces" + end + + it "accepts a title containing a unicode en dash" do + en_dash = "–" + record = <<~TXT + CEM Code 3N200 + AFSC 3N291, Superintendent + PREMIER BAND #{en_dash} THE USAF BAND + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Premier Band - The Usaf Band" + end + + # Real 4J0X2 (Diet Therapy): the subdivision superintendent 4J090 carries + # specific digit 0, while the specialty's own levels are 4J0X2. The record must + # key by the shared specific digit of the lower levels, not the superintendent. + it "keys by the lower levels' specific digit, not a specific-0 superintendent" do + record = <<~TXT + CEM Code 4J000 + AFSC 4J090, Superintendent + AFSC 4J072*, Craftsman + AFSC 4J052*, Journeyman + AFSC 4J032*,Apprentice + AFSC 4J012, Helper + DIET THERAPY + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"4J0X2" + _(result[:career_field]).must_equal :"4J" + _(result[:skill_levels][9]).must_equal({code: "4J090", title: "Superintendent"}) + _(result[:skill_levels][7]).must_equal({code: "4J072", title: "Craftsman"}) + _(result[:skill_levels][1]).must_equal({code: "4J012", title: "Helper"}) + end + + # Real 3E4X1 ladder lines carry an alternate code, e.g. "3E471 or 3E471A". + it "parses a ladder line that lists an alternate 'or' code" do + record = <<~TXT + CEM Code 3E000 + AFSC 3E471 or 3E471A, Craftsman + AFSC 3E451 or 3E451A, Journeyman + AFSC 3E431*,Apprentice + AFSC 3E411, Helper + WATER AND FUEL SYSTEMS MAINTENANCE + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"3E4X1" + _(result[:skill_levels][7]).must_equal({code: "3E471", title: "Craftsman"}) + _(result[:skill_levels][5]).must_equal({code: "3E451", title: "Journeyman"}) + end + + # Real 6C091 ladder line carries a trailing acronym: "Senior Enlisted Leader (SEL)". + it "parses a ladder line with a trailing parenthetical acronym" do + record = <<~TXT + CEM Code 6C000 + AFSC 6C091, Senior Enlisted Leader (SEL) + AFSC 6C071, Craftsman + AFSC 6C051, Journeyman + CONTRACTING + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:skill_levels][9]).must_equal({code: "6C091", title: "Senior Enlisted Leader"}) + end + + # SUFFIX-GLUE (real 1A1X4, Multi-domain Operations Aviator): pdf-reader shifts + # each "AFSC" prefix to the end of the previous line, leaving bare codes at + # line start and a trailing "AFSC", e.g. "1A194, SuperintendentAFSC". + it "parses a suffix-glued ladder (bare code at start, trailing AFSC)" do + record = <<~TXT + CEM Code 1A100 + 1A194, SuperintendentAFSC + 1A174, CraftsmanAFSC + 1A154, JourneymanAFSC + 1A134,ApprenticeAFSC + 1A114, Helper + MULTI-DOMAIN OPERATIONS AVIATOR + (Established 31 Oct 2024) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"1A1X4" + _(result[:career_field]).must_equal :"1A" + _(result[:changed_date]).must_equal "2024-10-31" + _(result[:skill_levels][9]).must_equal({code: "1A194", title: "Superintendent"}) + _(result[:skill_levels][7]).must_equal({code: "1A174", title: "Craftsman"}) + _(result[:skill_levels][5]).must_equal({code: "1A154", title: "Journeyman"}) + _(result[:skill_levels][3]).must_equal({code: "1A134", title: "Apprentice"}) + _(result[:skill_levels][1]).must_equal({code: "1A114", title: "Helper"}) + end + + # PREFIX-GLUE (real 1A1X8): a wrapped "Leader" glues to the next ladder line's + # "AFSC", e.g. "LeaderAFSC 1A178*, Craftsman", hiding the level-7 code. + it "parses a prefix-glued ladder (Word glued to AFSC)" do + record = <<~TXT + CEM Code 1A100 + AFSC 1A198*, Senior Enlisted + LeaderAFSC 1A178*, Craftsman + AFSC 1A158*, Journeyman + AFSC 1A138*,Apprentice + AFSC 1A118, Helper + EXECUTIVE MISSION AVIATOR + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"1A1X8" + _(result[:skill_levels][9]).must_equal({code: "1A198", title: "Senior Enlisted Leader"}) + _(result[:skill_levels][7]).must_equal({code: "1A178", title: "Craftsman"}) + _(result[:skill_levels][1]).must_equal({code: "1A118", title: "Helper"}) + end + + it "does not treat a prose AFSC mention as a ladder line" do + record = <<~TXT + CEM Code 1B000 + AFSC 1B491, Superintendent + CYBER WARFARE OPERATIONS + (Changed 30 Apr 24) + + 1. Specialty Summary. + 3.4.1. 1B451. Qualification in and possession ofAFSC 1B431 and experience + performing functions such as CNO/cryptologic activities is mandatory. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"1B4X1" + # Only the real ladder line (1B491) is captured; the prose codes are ignored. + _(result[:skill_levels].keys.sort).must_equal [9] + end + + # Real 1A1X4 / 1A1X8 titles arrive in Title Case, not all caps. They must + # still be captured (bounded by the date / summary line), not dropped. + it "captures a mixed-case (title-case) title" do + record = <<~TXT + CEM Code 1A100 + 1A194, SuperintendentAFSC + 1A114, Helper + Multi-domain Operations Aviator + (Established 31 Oct 2024) + 1.Specialty Summary. Leads. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Multi-Domain Operations Aviator" + _(result[:raw_title]).must_equal "Multi-domain Operations Aviator" + end + + it "exposes the raw pre-titlecase title for the de-gluing inventory" do + result = GovCodes::Dafecd::SpecialtyParser.new(mobility_record).parse + _(result[:raw_title]).must_equal "MOBILITY FORCEAVIATOR" + _(result[:name]).must_equal "Mobility Forceaviator" + end + + it "stops the title at a 'Special Duty Summary' section" do + record = <<~TXT + CEM Code 1A100 + AFSC 1A118, Helper + Executive Mission Aviator + 1. Special Duty Summary. Determines tasks. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Executive Mission Aviator" + end + + # Real 2A3X7: pdf-reader pads the title with a long run of spaces + # ("(5TH GENERATION)"). Internal padding must collapse so the + # title is recognized (not rejected as over-long). + it "collapses long internal padding within a title" do + record = <<~TXT + CEM Code 2A300 + AFSC 2A317*, Helper + TACTICALAIRCRAFT MAINTENANCE (5 TH GENERATION) + (Changed 31 Oct 21) + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:specialty]).must_equal :"2A3X7" + _(result[:raw_title]).must_equal "TACTICALAIRCRAFT MAINTENANCE (5 TH GENERATION)" + _(result[:name]).must_equal "Tacticalaircraft Maintenance (5 Th Generation)" + end + + # Real 1C8X3 uses an "(Effective ...)" annotation on its own line after the + # title; it must bound the title (not leak in) and be captured as the date. + it "treats an (Effective ...) annotation as the date boundary" do + record = <<~TXT + CEM Code 1C800 + AFSC 1C893, Superintendent + RADAR, AIRFIELD & WEATHER SYSTEMS (RAWS) + (Effective 30 Apr 23) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:raw_title]).must_equal "RADAR, AIRFIELD & WEATHER SYSTEMS (RAWS)" + _(result[:changed_date]).must_equal "2023-04-30" + end + + it "preserves parenthesized acronyms and joins a two-line title" do + record = <<~TXT + CEM Code 1U100 + AFSC 1U171, Craftsman + REMOTELY PILOTED AIRCRAFT (RPA) + SENSOR OPERATOR + (Changed 31 Oct 25) + + 1. Specialty Summary. + TXT + result = GovCodes::Dafecd::SpecialtyParser.new(record).parse + _(result[:name]).must_equal "Remotely Piloted Aircraft (RPA) Sensor Operator" + end +end diff --git a/test/gov_codes/dafecd/title_degluer_test.rb b/test/gov_codes/dafecd/title_degluer_test.rb new file mode 100644 index 0000000..46f7f9a --- /dev/null +++ b/test/gov_codes/dafecd/title_degluer_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/autorun" +require "gov_codes/dafecd/title_degluer" + +describe GovCodes::Dafecd::TitleDegluer do + let(:degluer) { + GovCodes::Dafecd::TitleDegluer.new( + "1A1X2": "Mobility Force Aviator", + "1Z3X1": "Tactical Air Control Party (TACP)" + ) + } + + it "returns the clean override for a known specialty" do + _(degluer.override_for(:"1A1X2")).must_equal "Mobility Force Aviator" + end + + it "returns nil for a specialty with no override" do + _(degluer.override_for(:"9Z9X9")).must_be_nil + end + + it "normalizes by stripping all whitespace and downcasing" do + _(GovCodes::Dafecd::TitleDegluer.norm("Mobility Force Aviator")).must_equal "mobilityforceaviator" + _(GovCodes::Dafecd::TitleDegluer.norm("MOBILITY FORCEAVIATOR")).must_equal "mobilityforceaviator" + end + + it "accepts an override that differs from source only in spacing and case" do + _(GovCodes::Dafecd::TitleDegluer.matches_source?("Mobility Force Aviator", "MOBILITY FORCEAVIATOR")).must_equal true + end + + it "rejects an override that changes a letter" do + _(GovCodes::Dafecd::TitleDegluer.matches_source?("Mobility Force Aviatorr", "MOBILITY FORCEAVIATOR")).must_equal false + _(GovCodes::Dafecd::TitleDegluer.matches_source?("Mobility Naval Aviator", "MOBILITY FORCEAVIATOR")).must_equal false + end + + it "rejects when there is no raw source title to compare against" do + _(GovCodes::Dafecd::TitleDegluer.matches_source?("Anything", nil)).must_equal false + end + + it "loads the shipped overrides file with symbol keys" do + loaded = GovCodes::Dafecd::TitleDegluer.load + _(loaded.override_for(:"1A1X2")).must_equal "Mobility Force Aviator" + _(loaded.override_for(:"1C3X1")).must_equal "All-Domain Command and Control Operations" + end +end From 1cea6af39c7f2f4a586d7ff1986a1539a8b248f6 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Thu, 9 Jul 2026 09:33:05 -0400 Subject: [PATCH 3/3] Source enlisted AFSC data from the DAFECD Replace the Wikipedia-derived enlisted data with the official DAF Enlisted Classification Directory (31 Oct 2025), extracted verbatim. Concrete skill-level codes now resolve, and lookups are versioned by document release date (defaulting to the latest). Added: Concrete skill-level AFSC code lookup (e.g. 1A172Y) Added: Version-aware lookup via find(code, as_of:) with effective_date Changed: Source enlisted AFSC data from the official DAFECD instead of Wikipedia --- README.md | 87 +- lib/gov_codes/afsc.rb | 11 +- lib/gov_codes/afsc/enlisted.rb | 173 +- lib/gov_codes/afsc/enlisted.yml | 544 ---- lib/gov_codes/afsc/releases.rb | 162 + lib/gov_codes/afsc/releases.yml | 6 + .../releases/dafecd/2025-10-31/enlisted.yml | 2721 +++++++++++++++++ test/gov_codes/afsc/enlisted_concrete_test.rb | 85 + test/gov_codes/afsc/enlisted_coverage_test.rb | 170 +- test/gov_codes/afsc/enlisted_release_test.rb | 185 ++ test/gov_codes/afsc/enlisted_test.rb | 103 +- test/gov_codes/afsc/releases_test.rb | 178 ++ test/gov_codes/afsc_test.rb | 6 +- test/gov_codes/afsc_versioned_test.rb | 69 + 14 files changed, 3644 insertions(+), 856 deletions(-) delete mode 100644 lib/gov_codes/afsc/enlisted.yml create mode 100644 lib/gov_codes/afsc/releases.rb create mode 100644 lib/gov_codes/afsc/releases.yml create mode 100644 lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml create mode 100644 test/gov_codes/afsc/enlisted_concrete_test.rb create mode 100644 test/gov_codes/afsc/enlisted_release_test.rb create mode 100644 test/gov_codes/afsc/releases_test.rb create mode 100644 test/gov_codes/afsc_versioned_test.rb diff --git a/README.md b/README.md index f98b585..03ed15f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,34 @@ require 'gov_codes/afsc' # Find an enlisted AFSC code code = GovCodes::AFSC.find("1A1X2") -puts code.name # => "Mobility force aviator" +puts code.name # => "Mobility Force Aviator" puts code.career_field # => "1A" puts code.career_field_subdivision # => "1A1" puts code.skill_level # => "X" puts code.specific_afsc # => "1A1X2" puts code.shredout # => nil +puts code.effective_date # => # + +# Look up the concrete code an HR system actually stores +# (the "7" is the airman's skill level: Craftsman) +code = GovCodes::AFSC.find("1A172Y") +code.specialty_name # => "Mobility Force Aviator" +code.skill_level_number # => 7 +code.skill_level_name # => "Craftsman" (title comes from the directory) +code.specialty # => :"1A1X2" + +# shredout_name resolves the shredout's meaning when that shredout +# exists in the data (nil otherwise) +code = GovCodes::AFSC.find("1A172A") +code.shredout_name # => "C-5 Flight Engineer" + +# Enlisted lookups are versioned by DAFECD release (published each 30 Apr and +# 31 Oct). `find`/`search` default to the latest shipped release; pass `as_of:` +# (a Date or "YYYY-MM-DD" string) to resolve the release in effect on that date. +code = GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01") +code.effective_date # => # +# A date before the earliest shipped release has no data and returns nil. +GovCodes::AFSC.find("1A172Y", as_of: "2000-01-01") # => nil # Find an officer AFSC code code = GovCodes::AFSC.find("11MX") @@ -72,9 +94,9 @@ results.each do |code| end # Output: # 1Z1X1: Pararescue -# 1Z2X1: Combat control -# 1Z3X1: Tactical air control party (TACP) -# 1Z4X1: Special reconnaissance +# 1Z2X1: Combat Control +# 1Z3X1: Tactical Air Control Party (TACP) +# 1Z4X1: Special Reconnaissance # Search for Bomber Pilot shredouts results = GovCodes::AFSC.search("11BX") @@ -95,21 +117,56 @@ GovCodes::AFSC.search("1z1") # Same as search("1Z1") ### Extending with Custom AFSC Codes -You can extend the default AFSC codes with your own custom codes by placing a YAML file in your application's load path: +Enlisted codes are stored as a specialty-keyed index per DAFECD release. You can +extend or override a release by dropping an index file for that release's +effective date onto your application's load path: + +```yaml +# In your application's lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml +:"9Z9X9": + :name: Custom Specialty + :career_field: :"9Z" + :skill_levels: + 7: + :code: 9Z979 + :title: Craftsman + :shredouts: + :A: Custom Shredout +``` + +The gem merges your index over the shipped index for the matching release, +adding new specialties and overriding existing ones. + +You can also add a whole new release (e.g. a newer directory the gem has not +shipped yet) by listing it in a `releases.yml` on your load path. Release lists +are unioned by effective date, so adding a release never hides the shipped ones; +a same-date entry from your file overrides the shipped manifest entry: + +```yaml +# In your application's lib/gov_codes/afsc/releases.yml +:dafecd: +- :effective_date: '2026-04-30' + :version_label: v3.6 + :source: DAFECD-30-April-26.pdf + :name: Department of the Air Force Enlisted Classification Directory +``` + +Pair it with a matching `releases/dafecd/2026-04-30/enlisted.yml` index, and +`find(code, as_of: "2026-04-30")` will resolve against it. + +## Data source & provenance + +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. + +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 stood for a given release, or take the latest: ```ruby -# In your application's lib/gov_codes/afsc/enlisted.yml -9Z: - name: Custom AFSC - subcategories: - 0X1: - name: Custom Subcategory - subcategories: - A: - name: Custom Shredout +GovCodes::AFSC.find("1A172Y") # latest shipped release +GovCodes::AFSC.find("1A172Y", as_of: "2025-11-01") # the release in effect on that date +GovCodes::AFSC.find("1A172Y").effective_date # => the release the result came from ``` -The gem will automatically merge your custom codes with the default codes, overriding any existing codes. +**Currently shipped:** enlisted AFSCs from the DAFECD effective 31 October 2025. Officer (DAFOCD), reporting/special-duty identifiers, SEIs, prefixes, and Space Force codes are planned. ## Development diff --git a/lib/gov_codes/afsc.rb b/lib/gov_codes/afsc.rb index e95ce8a..1147142 100644 --- a/lib/gov_codes/afsc.rb +++ b/lib/gov_codes/afsc.rb @@ -4,15 +4,18 @@ module GovCodes module AFSC - def self.find(code) - AFSC::Enlisted.find(code) || + # Resolve a code as of the DAFECD release in effect on +as_of+ (default: the + # latest shipped release). +as_of+ applies to the versioned enlisted lookup; + # Officer/RI are unversioned. + def self.find(code, as_of: nil) + AFSC::Enlisted.find(code, as_of: as_of) || AFSC::Officer.find(code) || AFSC::RI.find(code) end - def self.search(prefix) + def self.search(prefix, as_of: nil) results = [] - results.concat(Enlisted.search(prefix)) + results.concat(Enlisted.search(prefix, as_of: as_of)) results.concat(Officer.search(prefix)) results.concat(RI.search(prefix)) results diff --git a/lib/gov_codes/afsc/enlisted.rb b/lib/gov_codes/afsc/enlisted.rb index a23c6cb..52c5b80 100644 --- a/lib/gov_codes/afsc/enlisted.rb +++ b/lib/gov_codes/afsc/enlisted.rb @@ -1,10 +1,17 @@ require "strscan" -require "yaml" -require_relative "../data_loader" +require_relative "releases" module GovCodes module AFSC module Enlisted + SKILL_LEVELS = { + 1 => "Helper", + 3 => "Apprentice", + 5 => "Journeyman", + 7 => "Craftsman", + 9 => "Senior Enlisted Leader" + }.freeze + class Parser def initialize(code) @code = code @@ -18,6 +25,10 @@ def parse career_field: nil, career_field_subdivision: nil, skill_level: nil, + skill_level_number: nil, + skill_level_name: nil, + specialty: nil, + specialty_name: nil, specific_afsc: nil, subcategory: nil, shredout: nil @@ -41,20 +52,29 @@ def parse return result unless subdivision_digit result[:career_field_subdivision] = :"#{result[:career_field]}#{subdivision_digit}" - # Scan for skill level letter (usually X) - skill_level_letter = scanner.scan(/[A-Z]/) - return result unless skill_level_letter - result[:skill_level] = skill_level_letter.to_sym + # Scan for skill level: an X placeholder OR a concrete digit. + # NOTE: any digit is accepted here for now; validating that it is a + # real skill level (1/3/5/7/9) is deferred to Phase C. + skill_char = scanner.scan(/[A-Z0-9]/) + return result unless skill_char + result[:skill_level] = skill_char.to_sym + + # Concrete skill level -> numeric level + standard title + if skill_char.match?(/\d/) + result[:skill_level_number] = Integer(skill_char) + result[:skill_level_name] = SKILL_LEVELS[result[:skill_level_number]] + end - # Scan for skill level digit - skill_level_digit = scanner.scan(/\d/) - return result unless skill_level_digit + # Scan for the specific AFSC digit + specific_digit = scanner.scan(/\d/) + return result unless specific_digit - # Subcategory is subdivision digit + letter + digit (e.g. '1X2') - result[:subcategory] = :"#{subdivision_digit}#{skill_level_letter}#{skill_level_digit}" + # Normalize to the X-form specialty key regardless of concrete skill level + result[:subcategory] = :"#{subdivision_digit}X#{specific_digit}" + result[:specialty] = :"#{result[:career_field_subdivision]}X#{specific_digit}" - # Build specific AFSC - result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_level_letter}#{skill_level_digit}" + # specific_afsc preserves the code exactly as entered (concrete or generic) + result[:specific_afsc] = :"#{result[:career_field_subdivision]}#{skill_char}#{specific_digit}" # Scan for shredout (optional) result[:shredout] = scanner.scan(/[A-Z]/)&.to_sym @@ -66,9 +86,8 @@ def parse end end - extend GovCodes::DataLoader - - DATA = data + CODES = {} + private_constant :CODES Code = Data.define( :prefix, @@ -76,15 +95,29 @@ def parse :career_field, :career_field_subdivision, :skill_level, + :skill_level_number, + :skill_level_name, + :specialty, + :specialty_name, :specific_afsc, :subcategory, :shredout, - :name + :shredout_name, + :name, + :effective_date ) - def self.find(code) + # Resolve an enlisted AFSC against the DAFECD release in effect on +as_of+ + # (default: the latest shipped release). Returns nil when the code does not + # parse, when the specialty is absent from the resolved release, or when + # +as_of+ precedes the earliest shipped release. + def self.find(code, as_of: nil) code = code.to_s - CODES[code] ||= begin + # Key the memo on the RESOLVED release date so equivalent as_of values + # (nil, the Date, its string form, any date in the same release window) + # share one slot instead of growing unbounded for time-series callers. + effective_date = Releases.effective_date_for(as_of: as_of) + CODES[[code, effective_date]] ||= begin parser = Parser.new(code) result = parser.parse @@ -101,81 +134,59 @@ def self.find(code) return nil if code.length > 7 || code.match?(/[^A-Z0-9]/) - # Find the name by recursively searching the codes hash - name = find_name_recursive(result) + # Resolve the specialty entry from the versioned, specialty-keyed index + index = Releases.enlisted_index(as_of: as_of) + entry = index[result[:specialty]] + return nil unless entry + + specialty_name = entry[:name] - # Return nil if name is 'Unknown' - return nil if name == "Unknown" + # Skill-level title: prefer the directory's title for this specialty, + # falling back to the universal enlisted skill-level map. + if (number = result[:skill_level_number]) + result[:skill_level_name] = entry.dig(:skill_levels, number, :title) || + SKILL_LEVELS[number] + end + + # Shredout meaning, only when the directory documents this shredout. + shredout_name = result[:shredout] && entry.dig(:shredouts, result[:shredout]) - # Add the name to the result + name = shredout_name || specialty_name + return nil if name.nil? + + result[:shredout_name] = shredout_name + result[:specialty_name] = specialty_name result[:name] = name + result[:effective_date] = effective_date - # Create a new Code object with the result Code.new(**result) end end - def self.find_name_recursive(result) - data = DATA - - # Career field (e.g., "9Z") - cf = result[:career_field]&.to_sym - return "Unknown" unless cf && data[cf] - name = data[cf][:name] - data = data[cf][:subcategories] - - # Subcategory (e.g., "0X1" from "9Z0X1") - if data && result[:subcategory] - sub = result[:subcategory].to_sym - lookup_value = data[sub] - if lookup_value - if lookup_value.is_a?(Hash) - name = lookup_value[:name] || name - data = lookup_value[:subcategories] - else - # String value (leaf node) - name = lookup_value - data = nil - end - end - end - - # Shredout (optional, e.g., :A) - if data && result[:shredout] - lookup_value = data[result[:shredout]] - if lookup_value - name = lookup_value.is_a?(Hash) ? (lookup_value[:name] || name) : (lookup_value || name) - end - end - - name || "Unknown" + # Clears the memoized lookups and resets the versioned release loader. + # The +lookup+ keyword is accepted for interface parity with Officer/RI; + # the versioned index is resolved from the load path at lookup time. + def self.reset_data(lookup: $LOAD_PATH) + Releases.reset! + CODES.clear end - def self.search(prefix) - results = [] + def self.search(prefix, as_of: nil) prefix = prefix.to_s.upcase - collect_codes_recursive(DATA, "", prefix, results) - results.map { |code| find(code) }.compact - end - - def self.collect_codes_recursive(data, current_code, prefix, results) - return unless data.is_a?(Hash) - - data.each do |key, value| - code = "#{current_code}#{key}" - - if value.is_a?(Hash) && value[:name] - # This is a node with a name and possibly subcategories - results << code if code.start_with?(prefix) - collect_codes_recursive(value[:subcategories], code, prefix, results) if value[:subcategories] - elsif value.is_a?(String) - # This is a leaf node (simple string value) - results << code if code.start_with?(prefix) - elsif value.is_a?(Hash) - # Nested subcategories without a name at this level - collect_codes_recursive(value, current_code, prefix, results) + index = Releases.enlisted_index(as_of: as_of) + + codes = [] + index.each do |specialty, entry| + specialty_code = specialty.to_s + codes << specialty_code + (entry[:shredouts] || {}).each_key do |shredout| + codes << "#{specialty_code}#{shredout}" end end + + codes.select { |code| code.start_with?(prefix) } + .map { |code| find(code, as_of: as_of) } + .compact end end end diff --git a/lib/gov_codes/afsc/enlisted.yml b/lib/gov_codes/afsc/enlisted.yml deleted file mode 100644 index 978bd17..0000000 --- a/lib/gov_codes/afsc/enlisted.yml +++ /dev/null @@ -1,544 +0,0 @@ -# Enlisted AFSCs extracted from Wikipedia -# Source: https://en.wikipedia.org/wiki/Air_Force_Specialty_Code -# Wikipedia Revision: 1318430021 -# Extracted: 2025-11-15 -# -# IMPORTANT: This file contains ONLY codes explicitly listed on Wikipedia. -# No predictions, no assumptions, no hallucinations. -# See .agent-os/product/decisions.md (DEC-003) for anti-hallucination policy. -# -# Extraction script: bin/extract_afsc_from_wikipedia.rb -# Verification: bundle exec ruby test/verification/wikipedia_match_test.rb - -:1A: - :name: Aircrew operations - :subcategories: - :1X2: - :name: Mobility force aviator - :subcategories: - :A: C-5 flight engineer - :B: C-5 loadmaster - :C: C-17 loadmaster - :D: C-130J loadmaster - :E: WC-130J loadmaster - :F: E-3 flight engineer - :G: KC-46 boom operator - :H: KC-135 boom operator - :I: KC-10 boom operator - :J: KC-10 flight engineer - :K: E-8 flight engineer - :L: C-130H flight engineer - :N: C-130H loadmaster - :O: EC-130H flight engineer - :Z: Data mask mobility force aviator - :1X3: - :name: Special mission aviator - :subcategories: - :A: AC-130J gunner - :B: CV-22 flight engineer - :C: UH-1N flight engineer - :D: HC-130J loadmaster - :E: MC-130J loadmaster - :F: HH-60 flight engineer - :G: MH-139 flight engineer - :H: C-146 loadmaster - :Z: SMA data masked - :1X4: Multi-Domain Operations Aviator - :1X8: - :name: Executive mission aviator - :subcategories: - :A: C-32/C-40 flight attendant - :B: C-32/C-40 communications systems operator - :C: C-37 flight attendant - :D: C-37 flight engineer - :E: CEM executive mission aviator - :F: C-37 communications systems operator - :G: E-4 flight engineer - :H: E-4 flight attendant - :I: E-4 communications systems operator/technician/controller - :J: Presidential Airlift Group, flight engineer - :K: Presidential Airlift Group, flight attendant - :L: Presidential Airlift Group, communication system operator - :Z: EMA data mask - :3X1: - :name: Airborne mission systems specialist - :subcategories: - :A: C-32/C-40 - :D: C-37 - :G: HC-130P/N - :H: EC-130J - :I: E-3 - :J: E-4 - :K: E-8 - :L: EC-130H - :N: RC-135 - :O: RQ-4 - :T: MC-130P - :890: SEL airborne intelligence, surveillance and reconnaissance (ISR) - :8X1: - :name: Airborne cryptologic language analyst - :subcategories: - :F: Arabic - :G: Chinese - :H: Korean - :I: Russian - :J: Spanish - :K: Persian - :M: Pashto - :Z: Divested languages - :8X2: Airborne intelligence, surveillance, and reconnaissance operator -:1B: - :name: Cyber warfare - :subcategories: - :4X1: Cyber warfare operations -:1C: - :name: Command and control systems operations - :subcategories: - :0X2: Aviation resource management - :1X1: Air traffic control - :3X1: All-domain command and control operations (C2 OPS) - :5X1: - :name: Battle management ops - :subcategories: - :D: Weapons director - :6X1: Space systems operations - :7X1: Airfield management - :8X3: Radar, airfield & weather systems (RAWS) -:1D: - :name: Warfighter Communication - :subcategories: - :7X1: - :name: Information Technology Systems - :subcategories: - :A: Network Operations - :B: Systems Administration - :D: Security operations (to be phased out) - :E: Client systems operations (to be phased out) - :K: Knowledge operations (to be phased out) - :M: Mission Defense Activities - :P: Data Operations - :Q: Enterprise Operations - :W: Expeditionary communications (XCOMM) - :Z: Software development operations (to be phased out) - :7X2: - :name: Radio Frequency Transmissions and Electromagnetic Activities - :subcategories: - :F: Spectrum Operations - :R: RF Transmissions - :7X3: Cable and Antenna - :7X4: Data Engineering - :7X5: Cybersecurity -:1N: - :name: Intelligence - :subcategories: - :0X1: All source intelligence analyst - :092: Intelligence superintendent - :1X1: - :name: Geospatial intelligence - :subcategories: - :A: Imagery analyst - :292: Cryptologic intelligence superintendent - :2X1: - :name: Signals intelligence - :subcategories: - :A: Electronic non-communications analyst - :C: Communications analyst - :3X1: - :name: Cryptologic language analyst - :subcategories: - :F: Arabic - :G: Chinese - :H: Korean - :I: Russian - :J: Spanish - :K: Persian - :M: Pashto - :N: Urdu - :Z: Divested languages - :4X1: - :name: Cyber intelligence - :subcategories: - :A: Analyst - :4X2: Cryptologic analyst & reporter - :7X1: Human intelligence specialist - :8X1: Targeting analyst -:1P: - :name: Aircrew flight equipment - :subcategories: - :0X1: - :name: Aircrew flight equipment - :subcategories: - :A: Ejection seat aircraft - :B: Non-ejection seat aircraft -:1S: - :name: Safety - :subcategories: - :0X1: Safety -:1T: - :name: Special warfare enabler - :subcategories: - :0X1: Survival, evasion, resistance, escape (SERE) specialist -:1U: - :name: Aircrew operations (RPA) - :subcategories: - :1X1: - :name: Remotely piloted aircraft (RPA) pilot - :subcategories: - :O: RQ-4 - :R: MQ-9 -:1W: - :name: Weather - :subcategories: - :0X1: - :name: Weather - :subcategories: - :A: Weather Forecaster -:1Z: - :name: Special warfare - :subcategories: - :1X1: Pararescue - :2X1: Combat control - :3X1: Tactical air control party (TACP) - :4X1: Special reconnaissance -:1H: - :name: Aerospace physiology - :subcategories: - :0X1: Aerospace physiology -:2A: - :name: Aerospace maintenance - :subcategories: - :090: Avionics - :0X1: - :name: Avionics test station, components, and electronic warfare systems - :subcategories: - :K: A-10/B-2/C-17/CV-22/F-16/AFSOC avionics systems - :M: B-1/E-8/F-15 avionics systems - :P: Avionics sensor systems and electronic warfare systems - :2X1: MHU-139 electrical, environmental and avionics technician - :390: Fighter/remotely piloted aircraft maintenance - :3X3: - :name: Tactical aircraft maintenance - :subcategories: - :E: A-10/U-2 - :L: F-15 - :M: F-16 - :3X4: - :name: Fighter aircraft integrated avionics - :subcategories: - :A: A-10/U-2 - :B: F-15 - :C: F-16 - :3X5: - :name: Advanced fighter aircraft integrated avionics - :subcategories: - :A: F-22 - :B: F-35 - :C: MQ-1/MQ-9/RQ-4 - :3X7: - :name: Tactical aircraft maintenance (5th generation) - :subcategories: - :A: F-22 - :B: F-35 - :3X8: - :name: Remotely piloted aircraft maintenance - :subcategories: - :A: MQ-1/MQ-9 - :B: RQ-4 - :5X1: - :name: Airlift/special mission aircraft maintenance - :subcategories: - :A: C-20/C-21/C-22/C-37/C-40/E-4/VC-25 - :B: C-130/C-27J - :C: C-5 - :D: C-17 - :5X2: - :name: Helicopter/tiltrotor aircraft maintenance - :subcategories: - :B: HH-60 - :D: CV-22 - :E: MHU-139 - :5X4: - :name: Refuel/bomber aircraft maintenance - :subcategories: - :A: Any C-135/E-3/E-8 - :B: KC-10 - :C: KC-46 - :D: B-52 - :E: B-1 - :F: B-2 - :H: B-21 - :690: Aircraft accessories - :6X1: - :name: Aerospace propulsion - :subcategories: - :C: TF33, CF6, F103, F108, F117, TFE-731, TF39, PW 2040, F138 jet engines - (airlift, special mission, and B-52 aircraft) - :D: F100, F119, F135 jet engines (F-15, F16, F-22 aircraft) - :E: F101, F110, F118, TF34 jet engines (A-10, B-1, B-2, F-16, U-2 aircraft) - :F: F100, F101, F110, F118, F119, F135, TF34 jet engines (A-10, B-1, B-2, - F-15, F-16, F-22, F-35, U-2 aircraft) - :H: Turboprop and turboshaft propulsion (helicopter, propeller, tiltrotor - aircraft) - :6X2: Aerospace ground equipment (AGE) - :6X3: Aircrew egress systems - :6X4: Aircraft fuel systems - :6X5: Aircraft hydraulic systems - :6X6: Aircraft electrical and environmental systems - :790: Aircraft fabrication - :7X1: Aircraft metals technology - :7X2: Nondestructive inspection - :7X3: Aircraft structural maintenance - :7X5: Low observable aircraft structural maintenance - :9X4: - :name: Heavy aircraft integrated avionics - :subcategories: - :A: C4ISR mission systems (E-3, E-4, E-7, E-8, EC-130H, RC-135, VC-25) -:2F: - :name: Fuels - :subcategories: - :0X1: Fuels -:2G: - :name: Logistics plans - :subcategories: - :0X1: Logistics plans -:2M: - :name: Missile and space systems maintenance - :subcategories: - :090: Missile and space systems maintenance superintendent - :0X1: - :name: Missile and space systems electronic maintenance - :subcategories: - :A: ICBM - :B: ALCM - :0X2: Missile and space systems maintenance - :0X3: Missile and space facilities -:2P: - :name: Precision measurement equipment laboratory - :subcategories: - :0X1: Precision measurement equipment laboratory -:2R: - :name: Maintenance management - :subcategories: - :2X1: Maintenance management -:2S: - :name: Material management - :subcategories: - :0X1: Material management -:2T: - :name: Transportation & vehicle management - :subcategories: - :0X1: Traffic management - :1X1: Ground transportation - :2X1: Air transportation - :390: Vehicle management - :3X1: - :name: Mission generation vehicular equipment maintenance - :subcategories: - :A: Firefighting and refueling vehicle & equipment maintenance - :C: Material handling equipment (MHE)/463L maintenance - :3X7: Fleet management and analysis -:2W: - :name: Munitions & weapons - :subcategories: - :0X1: Munitions systems - :1X1: - :name: Aircraft armament systems - :subcategories: - :C: A-10 - :E: F-15 - :F: F-16 - :J: F-35 - :K: B-52/B-2 - :L: B-1 - :N: F-22 - :Q: MQ-1/MQ-9 - :Z: All other - :2X1: Nuclear weapons -:3E: - :name: Civil engineering - :subcategories: - :090: Facility systems - :0X1: Electrical systems - :0X2: Electrical power production - :1X1: Heating, ventilation, AC, and refrigeration - :290: Heavy repair - :2X1: Pavements and construction equipment - :3X1: Structural - :490: Infrastructure systems - :4X1: - :name: Water and fuel systems maintenance - :subcategories: - :A: Fuel systems maintenance - :4X3: Pest management - :5X1: Engineering - :6X1: Operations management - :7X1: Fire protection - :8X1: Explosive ordnance disposal - :9X1: Emergency management -:3F: - :name: Force support - :subcategories: - :0X1: Personnel - :1X1: Services - :2X1: Education and training - :3X1: Manpower - :4X1: Equal opportunity - :5X1: Administration -:3G: - :name: Talent acquisition - :subcategories: - :0X1: Talent acquisition -:3H: - :name: Historian - :subcategories: - :0X1: Historian -:3N: - :name: Public affairs - :subcategories: - :090: Public affairs superintendent - :0X6: Public affairs - :1X1: - :name: Regional band - :subcategories: - :A: Clarinet - :B: Saxophone - :C: Bassoon - :D: Oboe - :E: Flute - :F: Horn - :G: Trumpet - :H: Euphonium - :J: Trombone - :K: Tuba - :L: Percussion - :M: Piano - :N: Guitar - :P: Arranger - :Q: Jazz trumpet - :R: Vocalist - :S: String/electric bass - :U: Drum set - :V: Audio engineer - :Z: Instrumentalist, general (Air National Guard bands) - :2X1: Premier band – The United States Air Force Band - :3X1: Premier band – The United States Air Force Academy Band -:3P: - :name: Security forces - :subcategories: - :0X1: - :name: Security forces - :subcategories: - :A: Military working dog handler - :B: Combat arms -:4A: - :name: Health services - :subcategories: - :0X1: - :name: Health services management - :subcategories: - :S: Health information technology - :1X1: Medical material - :2X1: Biomedical equipment -:4B: - :name: Bioenvironmental engineering - :subcategories: - :0X1: Bioenvironmental engineering -:4C: - :name: Mental health - :subcategories: - :0X1: Mental health service -:4D: - :name: Diet therapy - :subcategories: - :0X1: Diet therapy -:4E: - :name: Public health - :subcategories: - :0X1: Public health -:4H: - :name: Respiratory care - :subcategories: - :0X1: Respiratory care practitioner -:4J: - :name: Physical medicine - :subcategories: - :0X2: - :name: Physical medicine - :subcategories: - :A: Orthotics -:4N: - :name: Aerospace medical service - :subcategories: - :0X1: - :name: Aerospace medical service - :subcategories: - :B: Neurodiagnostic medical technician - :C: Independent duty medical technician - :D: Allergy/immunization technician - :F: Flight and operational medical technician - :G: Aeromedical evacuation technician - :H: National registry paramedic - :1X1: - :name: Surgical technologist - :subcategories: - :B: Urology - :C: Orthopedics - :D: Otolaryngology -:4P: - :name: Pharmacy - :subcategories: - :0X1: Pharmacy -:4R: - :name: Diagnostic imaging - :subcategories: - :0X1: - :name: Diagnostic imaging - :subcategories: - :A: Nuclear medicine - :B: Diagnostic medical sonography - :C: Magnetic resonance imaging -:4T: - :name: Medical laboratory - :subcategories: - :090: Medical laboratory - :0X1: Medical laboratory - :0X2: Histopathology -:4V: - :name: Ophthalmic - :subcategories: - :0X1: - :name: Ophthalmic - :subcategories: - :S: Ophthalmology -:4Y: - :name: Dental - :subcategories: - :090: Dental - :0X1: - :name: Dental assistant - :subcategories: - :H: Dental hygienist - :0X2: Dental laboratory -:5J: - :name: Paralegal - :subcategories: - :0X1: Paralegal -:5R: - :name: Religious affairs - :subcategories: - :0X1: Religious affairs -:6C: - :name: Contracting - :subcategories: - :0X1: Contracting -:6F: - :name: Financial management - :subcategories: - :0X1: Financial management and comptroller -:7S: - :name: Special investigations - :subcategories: - :0X1: Special investigations -# Note: 8X (Special Duty Identifiers) and 9X (Reporting Identifiers) series -# are in ri.yml as they follow a different code structure (digit + letter + 3 digits) -# Use GovCodes::AFSC::RI.find() for codes like 8A400, 9Z200, etc. diff --git a/lib/gov_codes/afsc/releases.rb b/lib/gov_codes/afsc/releases.rb new file mode 100644 index 0000000..76a2b00 --- /dev/null +++ b/lib/gov_codes/afsc/releases.rb @@ -0,0 +1,162 @@ +require "date" +require "yaml" + +module GovCodes + module AFSC + # Loads the versioned DAFECD release manifest and per-release specialty + # index, and resolves the release in effect on a given date. + # + # See docs/plans/2026-07-08-afsc-pdf-representation-design.md + # "Versioning by document release". + # + # Storage layout (per release): + # lib/gov_codes/afsc/releases.yml # manifest + # lib/gov_codes/afsc/releases/dafecd//enlisted.yml # index + # + # Resolution: releases are sorted ascending by effective_date. A given + # `as_of` resolves to the release with the greatest effective_date <= as_of; + # `as_of: nil` resolves to the latest release. A date before the earliest + # shipped release resolves to no release (an empty index / nil date). + # + # Extensibility (DEC-004): both tiers merge across the load path (gem lib dir + # first, then the load path). The manifest's per-publication release list is + # unioned by effective_date -- a consumer releases.yml ADDS releases without + # hiding the shipped ones, and a same-date entry from the load path wins. For + # the resolved release, the release index found at each lookup path is merged, + # so a consumer may drop gov_codes/afsc/releases/dafecd//enlisted.yml to + # extend or override the shipped index. + module Releases + # Publication key for the enlisted directory (DAFECD). + PUBLICATION = "dafecd" + + class << self + def manifest(lookup: $LOAD_PATH) + manifest_cache[cache_key(lookup)] ||= load_manifest(lookup) + end + + def enlisted_index(as_of: nil, lookup: $LOAD_PATH) + key = [to_date(as_of), cache_key(lookup)] + index_cache[key] ||= load_enlisted_index(as_of: as_of, lookup: lookup) + end + + def effective_date_for(as_of: nil, lookup: $LOAD_PATH) + release = resolve_release(as_of: as_of, lookup: lookup) + release && release[:date] + end + + def reset! + @manifest_cache = {} + @index_cache = {} + end + + private + + def manifest_cache + @manifest_cache ||= {} + end + + def index_cache + @index_cache ||= {} + end + + def cache_key(lookup) + Array(lookup).dup + end + + def to_date(as_of) + return nil if as_of.nil? + return as_of if as_of.is_a?(Date) + Date.parse(as_of.to_s) + rescue Date::Error + raise ArgumentError, + "invalid as_of #{as_of.inspect}: expected a Date or a parseable date string (e.g. \"2025-10-31\")" + end + + # Ordered list of directories to search: the gem lib dir first, then the + # provided lookup path. Deduplicated to avoid loading the same file twice. + def lookup_paths(lookup) + gem_lib_dir = File.expand_path("../..", __dir__) + ([gem_lib_dir] + Array(lookup)).uniq + end + + def resolve_release(as_of:, lookup:) + releases = release_list(lookup) + return nil if releases.empty? + + target = to_date(as_of) + return releases.last if target.nil? + + releases.reverse.find { |r| r[:date] <= target } + end + + # Releases from the manifest, each annotated with a parsed :date and the + # :dir name used to locate its index, sorted ascending by date. + def release_list(lookup) + entries = manifest(lookup: lookup)[:dafecd] || [] + entries.filter_map do |entry| + raw = entry[:effective_date] + next unless raw + + date = raw.is_a?(Date) ? raw : Date.parse(raw.to_s) + dir = raw.is_a?(Date) ? raw.strftime("%Y-%m-%d") : raw.to_s + entry.merge(date: date, dir: dir) + end.sort_by { |r| r[:date] } + end + + def load_manifest(lookup) + merged = {} + each_yaml(lookup, ["gov_codes", "afsc", "releases.yml"]) do |data| + merge_manifest!(merged, data) + end + merged + end + + def load_enlisted_index(as_of:, lookup:) + release = resolve_release(as_of: as_of, lookup: lookup) + return {} unless release + + merged = {} + parts = ["gov_codes", "afsc", "releases", PUBLICATION, release[:dir], "enlisted.yml"] + each_yaml(lookup, parts) { |data| merged.merge!(data) } + merged + end + + # Merge a manifest hash into +acc+. Per-publication release lists are + # unioned by effective_date (a same-date entry from a later-loaded file + # wins); non-list values fall back to a plain overwrite. + def merge_manifest!(acc, incoming) + incoming.each do |publication, releases| + acc[publication] = if acc[publication].is_a?(Array) && releases.is_a?(Array) + union_releases(acc[publication], releases) + else + releases + end + end + acc + end + + def union_releases(base, overrides) + by_date = {} + (base + overrides).each do |entry| + by_date[entry[:effective_date]] = entry if entry.is_a?(Hash) + end + by_date.values + end + + # Yield each parsed YAML hash found at +parts+ across the lookup paths, + # skipping missing files and malformed YAML (graceful degradation). + def each_yaml(lookup, parts) + lookup_paths(lookup).each do |dir| + path = File.join(dir, *parts) + next unless File.exist?(path) + + data = YAML.load_file(path, symbolize_names: true) + yield data if data.is_a?(Hash) + rescue Psych::SyntaxError, TypeError + next + end + end + end + end + end +end diff --git a/lib/gov_codes/afsc/releases.yml b/lib/gov_codes/afsc/releases.yml new file mode 100644 index 0000000..a797d3c --- /dev/null +++ b/lib/gov_codes/afsc/releases.yml @@ -0,0 +1,6 @@ +--- +:dafecd: +- :effective_date: '2025-10-31' + :version_label: v3.5 + :source: DAFECD -31 October 25 v3.5 FINAL.pdf + :name: Department of the Air Force Enlisted Classification Directory diff --git a/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml b/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml new file mode 100644 index 0000000..fd21378 --- /dev/null +++ b/lib/gov_codes/afsc/releases/dafecd/2025-10-31/enlisted.yml @@ -0,0 +1,2721 @@ +# DAFECD enlisted AFSC index (specialty-keyed, X-form) +# Source: DAFECD -31 October 25 v3.5 FINAL.pdf +# Directory: Department of the Air Force Enlisted Classification Directory +# Effective date: 2025-10-31 (v3.5) +# Generated deterministically by bin/extract_afsc_from_pdf.rb (Phase C.1a). +# Do not edit by hand; re-run the extractor against the source PDF. +--- +:1A1X2: + :name: Mobility Force Aviator + :career_field: :1A + :cem_code: 1A100 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1A112 + :title: Helper + 3: + :code: 1A132 + :title: Apprentice + 5: + :code: 1A152 + :title: Journeyman + 7: + :code: 1A172 + :title: Craftsman + 9: + :code: 1A192 + :title: Senior Enlisted Leader + :shredouts: + :A: C-5 Flight Engineer + :B: C-5 Loadmaster + :C: C-17 Loadmaster + :D: C-130J Loadmaster + :E: WC-130 Loadmaster + :F: E-3 Flight Engineer + :G: KC-46 Boom Operator + :H: KC-135 Boom Operator + :L: C-130H Flight Engineer + :N: C-130H Loadmaster + :O: EC-130H Flight Engineer + :P: LC-130H Loadmaster + :Q: LC-130H Flight Engineer + :Y: General + :Z: Data Mask Mobility Force +:1A1X3: + :name: Special Mission Aviator + :career_field: :1A + :cem_code: 1A100 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1A113 + :title: Helper + 3: + :code: 1A133 + :title: Apprentice + 5: + :code: 1A153 + :title: Journeyman + 7: + :code: 1A173 + :title: Craftsman + 9: + :code: 1A193 + :title: Senior Enlisted Leader + :shredouts: + :A: AC-130J Gunner + :B: CV-22B Flight Engineer + :C: UH-1N/TH-1H Flight + :D: HC-130J Loadmaster + :E: MC-130J Loadmaster + :F: HH-60W Flight Engineer + :G: MH-139AFlight Engineer + :H: C-146ALoadmaster + :Y: General + :Z: SMAData Masked +:1A1X4: + :name: Multi-Domain Operations Aviator + :career_field: :1A + :cem_code: 1A100 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 1A114 + :title: Helper + 3: + :code: 1A134 + :title: Apprentice + 5: + :code: 1A154 + :title: Journeyman + 7: + :code: 1A174 + :title: Craftsman + 9: + :code: 1A194 + :title: Superintendent + :shredouts: + :A: E-3G Radar/Comm/Mission Operator + :B: E-3ARadar/Comm/Mission Operator + :D: E-11 Mission Coordinator / Payload Operator + :E: Technician + :F: MQ-9 Sensor Operator + :G: RC-135Airborne Systems Engineer + :H: RQ-4 Sensor Operator + :J: MPRIS - Multi-Platform RPA Integration System + :Y: General + :Z: Multi-domain OperationsAviator Data Mask +:1A1X8: + :name: Executive Mission Aviator + :career_field: :1A + :cem_code: 1A100 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1A118 + :title: Helper + 3: + :code: 1A138 + :title: Apprentice + 5: + :code: 1A158 + :title: Journeyman + 7: + :code: 1A178 + :title: Craftsman + 9: + :code: 1A198 + :title: Senior Enlisted Leader + :shredouts: + :A: C-32/C-40B/C FlightAttendant + :B: Operator + :C: C-37A/B FlightAttendant + :D: C-37A/B Flight Engineer + :F: C-37A/B Communications Systems Operator + :G: E-4B Flight Engineer + :H: E-4B FlightAttendant + :Y: General + :Z: EMAData Mask +:1A8X0: + :name: SEL Airborne Intelligence, Surveillance and Reconnaissance (ISR) + :career_field: :1A + :cem_code: 1A800 + :changed_date: '2025-10-31' + :skill_levels: + 9: + :code: 1A890 + :title: Superintendent + :shredouts: {} +:1A8X1: + :name: Airborne Cryptologic Language Analyst (ACLA) + :career_field: :1A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1A811 + :title: Helper + :shredouts: + :F: Arabic + :G: Chinese + :H: Korean + :I: Russian + :J: Spanish + :K: Persian + :Y: General + :Z: Divested Languages +:1A8X2: + :name: Airborne Intelligence, Surveillance, and Reconnaissance (ISR) Operator + :career_field: :1A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1A812 + :title: Helper + 3: + :code: 1A832 + :title: Apprentice + 5: + :code: 1A852 + :title: Journeyman + 7: + :code: 1A872 + :title: Craftsman + :shredouts: {} +:1B4X1: + :name: Cyber Warfare Operations + :career_field: :1B + :cem_code: 1B000 + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 1B411 + :title: Helper + 3: + :code: 1B431 + :title: Apprentice + 5: + :code: 1B451 + :title: Journeyman + 7: + :code: 1B471 + :title: Craftsman + 9: + :code: 1B491 + :title: Superintendent + :shredouts: {} +:1C0X2: + :name: Aviation Resource Management + :career_field: :1C + :cem_code: 1C000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1C012 + :title: Helper + 3: + :code: 1C032 + :title: Apprentice + 5: + :code: 1C052 + :title: Journeyman + 7: + :code: 1C072 + :title: Craftsman + 9: + :code: 1C092 + :title: Superintendent + :shredouts: {} +:1C1X1: + :name: Air Traffic Control + :career_field: :1C + :cem_code: 1C100 + :changed_date: '2021-04-30' + :skill_levels: + 1: + :code: 1C111 + :title: Helper + 3: + :code: 1C131 + :title: Apprentice + 5: + :code: 1C151 + :title: Journeyman + 7: + :code: 1C171 + :title: Craftsman + 9: + :code: 1C191 + :title: Superintendent + :shredouts: {} +:1C3X1: + :name: All-Domain Command and Control Operations + :career_field: :1C + :cem_code: 1C300 + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 1C311 + :title: Helper + 3: + :code: 1C331 + :title: Apprentice + 5: + :code: 1C351 + :title: Journeyman + 7: + :code: 1C371 + :title: Craftsman + 9: + :code: 1C391 + :title: Superintendent + :shredouts: {} +:1C5X1: + :name: Battle Management Operations + :career_field: :1C + :cem_code: 1C500 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 1C511 + :title: Helper + 3: + :code: 1C531 + :title: Apprentice + 5: + :code: 1C551 + :title: Journeyman + 7: + :code: 1C571 + :title: Craftsman + 9: + :code: 1C591 + :title: Superintendent + :shredouts: + :D: Weapons Director +:1C6X1: + :name: Space Systems Operations + :career_field: :1C + :cem_code: 1C600 + :changed_date: '2023-04-30' + :skill_levels: + 1: + :code: 1C611 + :title: Helper + 3: + :code: 1C631 + :title: Apprentice + 5: + :code: 1C651 + :title: Journeyman + 7: + :code: 1C671 + :title: Craftsman + 9: + :code: 1C691 + :title: Superintendent + :shredouts: {} +:1C7X1: + :name: Airfield Management + :career_field: :1C + :cem_code: 1C700 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 1C711 + :title: Helper + 3: + :code: 1C731 + :title: Apprentice + 5: + :code: 1C751 + :title: Journeyman + 7: + :code: 1C771 + :title: Craftsman + 9: + :code: 1C791 + :title: Superintendent + :shredouts: {} +:1C8X3: + :name: Radar, Airfield & Weather Systems (RAWS) + :career_field: :1C + :cem_code: 1C800 + :changed_date: '2023-04-30' + :skill_levels: + 1: + :code: 1C813 + :title: Helper + 3: + :code: 1C833 + :title: Apprentice + 5: + :code: 1C853 + :title: Journeyman + 7: + :code: 1C873 + :title: Craftsman + 9: + :code: 1C893 + :title: Superintendent + :shredouts: {} +:1D7X1: + :name: Warfighter Communications + :career_field: :1D + :cem_code: 1D700 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1D711 + :title: Helper + 3: + :code: 1D731 + :title: Apprentice + 5: + :code: 1D751 + :title: Journeyman + 7: + :code: 1D771 + :title: Craftsman + 9: + :code: 1D791 + :title: Superintendent + :shredouts: + :A: Network Operations + :B: SystemsAdministration +:1D7X2: + :name: Radio Frequency Transmissions and Electromagnetic Activities (EMA) + :career_field: :1D + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1D712 + :title: Helper + 3: + :code: 1D732 + :title: Apprentice + 5: + :code: 1D752 + :title: Journeyman + 7: + :code: 1D772 + :title: Craftsman + :shredouts: + :F: Spectrum Operations + :R: RF Transmissions +:1D7X3: + :name: Cable and Antenna + :career_field: :1D + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1D713 + :title: Helper + 3: + :code: 1D733 + :title: Apprentice + 5: + :code: 1D753 + :title: Journeyman + 7: + :code: 1D773 + :title: Craftsman + :shredouts: {} +:1D7X4: + :name: Data Engineering + :career_field: :1D + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1D714 + :title: Helper + 3: + :code: 1D734 + :title: Apprentice + 5: + :code: 1D754 + :title: Journeyman + 7: + :code: 1D774 + :title: Craftsman + :shredouts: + :D: Data Operations + :P: Software Engineer +:1D7X5: + :name: Cybersecurity + :career_field: :1D + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1D715 + :title: Helper + 3: + :code: 1D735 + :title: Apprentice + 5: + :code: 1D755 + :title: Journeyman + 7: + :code: 1D775 + :title: Craftsman + :shredouts: {} +:1H0X1: + :name: Aerospace Physiology + :career_field: :1H + :cem_code: 1H000 + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1H011 + :title: Helper + 3: + :code: 1H031 + :title: Apprentice + 5: + :code: 1H051 + :title: Journeyman + 7: + :code: 1H071 + :title: Craftsman + 9: + :code: 1H091 + :title: Superintendent + :shredouts: {} +:1N0X1: + :name: All Source Intelligence Analyst + :career_field: :1N + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 1N011 + :title: Helper + 3: + :code: 1N031 + :title: Apprentice + 5: + :code: 1N051 + :title: Journeyman + 7: + :code: 1N071 + :title: Craftsman + :shredouts: {} +:1N0X2: + :name: Intelligence Superintendent + :career_field: :1N + :changed_date: '2021-10-31' + :skill_levels: + 9: + :code: 1N092 + :title: Superintendent + :shredouts: {} +:1N1X1: + :name: Geospatial Intelligence (GEOINT) + :career_field: :1N + :cem_code: 1N000 + :changed_date: '2022-10-31' + :skill_levels: + 1: + :code: 1N111 + :title: Helper + 3: + :code: 1N131 + :title: Apprentice + 5: + :code: 1N151 + :title: Journeyman + 7: + :code: 1N171 + :title: Craftsman + :shredouts: + :A: ImageryAnalyst +:1N2X1: + :name: Signals Intelligence + :career_field: :1N + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 1N211 + :title: Helper + 3: + :code: 1N231 + :title: Apprentice + 5: + :code: 1N251 + :title: Journeyman + 7: + :code: 1N271 + :title: Craftsman + :shredouts: + :A: Electronic Non- CommunicationsAnalyst + :C: SignalsAnalyst +:1N2X2: + :name: Cryptologic Intelligence Superintendent + :career_field: :1N + :changed_date: '2021-10-31' + :skill_levels: + 9: + :code: 1N292 + :title: Superintendent + :shredouts: {} +:1N3X1: + :name: Cryptologic Language Analyst + :career_field: :1N + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 1N311 + :title: Helper + 3: + :code: 1N331 + :title: Apprentice + 5: + :code: 1N351 + :title: Journeyman + 7: + :code: 1N371 + :title: Craftsman + :shredouts: + :F: Arabic + :G: Chinese + :H: Korean + :I: Russian + :J: Spanish + :K: Persian + :M: Pashto + :Z: Divested Languages +:1N4X1: + :name: Cyber Intelligence + :career_field: :1N + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1N411 + :title: Helper + 3: + :code: 1N431 + :title: Apprentice + 5: + :code: 1N451 + :title: Journeyman + 7: + :code: 1N471 + :title: Craftsman + :shredouts: + :A: Analyst +:1N4X2: + :name: Cryptologic Analyst & Reporter + :career_field: :1N + :changed_date: '2021-10-31' + :skill_levels: + 1: + :code: 1N412 + :title: Helper + 3: + :code: 1N432 + :title: Apprentice + 5: + :code: 1N452 + :title: Journeyman + 7: + :code: 1N472 + :title: Craftsman + :shredouts: {} +:1N7X1: + :name: Human Intelligence Specialist + :career_field: :1N + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1N711 + :title: Helper + 3: + :code: 1N731 + :title: Apprentice + 7: + :code: 1N771 + :title: Craftsman + :shredouts: {} +:1N8X1: + :name: Targeting Analyst + :career_field: :1N + :cem_code: 1N000 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 1N811 + :title: Helper + 3: + :code: 1N831 + :title: Apprentice + 5: + :code: 1N851 + :title: Journeyman + 7: + :code: 1N871 + :title: Craftsman + :shredouts: {} +:1P0X1: + :name: Aircrew Flight Equipment + :career_field: :1P + :cem_code: 1P000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1P011 + :title: Helper + 3: + :code: 1P031 + :title: Apprentice + 5: + :code: 1P051 + :title: Journeyman + 7: + :code: 1P071 + :title: Craftsman + 9: + :code: 1P091 + :title: Superintendent + :shredouts: + :A: Ejection SeatAircraft + :B: Non-Ejection SeatAircraft +:1S0X1: + :name: Safety + :career_field: :1S + :cem_code: 1S000 + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1S011 + :title: Helper + 3: + :code: 1S031 + :title: Apprentice + 5: + :code: 1S051 + :title: Journeyman + 7: + :code: 1S071 + :title: Craftsman + 9: + :code: 1S091 + :title: Superintendent + :shredouts: {} +:1T0X1: + :name: Survival, Evasion, Resistance, Escape (SERE) Specialist + :career_field: :1T + :cem_code: 1T000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1T011 + :title: Helper + 3: + :code: 1T031 + :title: Apprentice + 5: + :code: 1T051 + :title: Journeyman + 7: + :code: 1T071 + :title: Craftsman + 9: + :code: 1T091 + :title: Superintendent + :shredouts: {} +:1U1X1: + :name: Remotely Piloted Aircraft (RPA) Pilot + :career_field: :1U + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1U111 + :title: Helper + 3: + :code: 1U131 + :title: Apprentice + 5: + :code: 1U151 + :title: Journeyman + 7: + :code: 1U171 + :title: Craftsman + 9: + :code: 1U191 + :title: Superintendent + :shredouts: + :O: RQ-4 + :R: MQ-9 +:1W0X1: + :name: Weather + :career_field: :1W + :cem_code: 1W000 + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 1W011 + :title: Helper + 3: + :code: 1W031 + :title: Apprentice + 5: + :code: 1W051 + :title: Journeyman + 7: + :code: 1W071 + :title: Craftsman + 9: + :code: 1W091 + :title: Superintendent + :shredouts: {} +:1Z1X1: + :name: Pararescue + :career_field: :1Z + :cem_code: 1Z100 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1Z111 + :title: Helper + 3: + :code: 1Z131 + :title: Apprentice + 5: + :code: 1Z151 + :title: Journeyman + 7: + :code: 1Z171 + :title: Craftsman + 9: + :code: 1Z191 + :title: Superintendent + :shredouts: {} +:1Z2X1: + :name: Combat Control + :career_field: :1Z + :cem_code: 1Z200 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1Z211 + :title: Helper + 3: + :code: 1Z231 + :title: Apprentice + 5: + :code: 1Z251 + :title: Journeyman + 7: + :code: 1Z271 + :title: Craftsman + 9: + :code: 1Z291 + :title: Superintendent + :shredouts: {} +:1Z3X1: + :name: Tactical Air Control Party (TACP) + :career_field: :1Z + :cem_code: 1Z300 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 1Z311 + :title: Helper + 3: + :code: 1Z331 + :title: Apprentice + 5: + :code: 1Z351 + :title: Journeyman + 7: + :code: 1Z371 + :title: Craftsman + 9: + :code: 1Z391 + :title: Superintendent + :shredouts: {} +:1Z4X1: + :name: Special Reconnaissance + :career_field: :1Z + :cem_code: 1Z400 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 1Z411 + :title: Helper + 3: + :code: 1Z431 + :title: Apprentice + 5: + :code: 1Z451 + :title: Journeyman + 7: + :code: 1Z471 + :title: Craftsman + 9: + :code: 1Z491 + :title: Superintendent + :shredouts: {} +:2A0X0: + :name: Avionics + :career_field: :2A + :changed_date: '2017-10-31' + :skill_levels: + 9: + :code: 2A090 + :title: Superintendent + :shredouts: {} +:2A0X1: + :name: Avionics Test Station, Components, and Electronic Warfare Systems + :career_field: :2A + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 2A011 + :title: Helper + 3: + :code: 2A031 + :title: Apprentice + 5: + :code: 2A051 + :title: Journeyman + 7: + :code: 2A071 + :title: Craftsman + :shredouts: {} +:2A2X1: + :name: MHU-139 Electrical, Environmental and Avionics Technician + :career_field: :2A + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2A211 + :title: Helper + 3: + :code: 2A231 + :title: Apprentice + 5: + :code: 2A251 + :title: Journeyman + :shredouts: {} +:2A3X0: + :name: Fighter Aircraft Maintenance + :career_field: :2A + :cem_code: 2A300 + :changed_date: '2024-10-31' + :skill_levels: + 9: + :code: 2A390 + :title: Superintendent + :shredouts: {} +:2A3X3: + :name: Tactical Aircraft Maintenance + :career_field: :2A + :changed_date: '2021-10-31' + :skill_levels: + 1: + :code: 2A313 + :title: Helper + 3: + :code: 2A333 + :title: Apprentice + 5: + :code: 2A353 + :title: Journeyman + 7: + :code: 2A373 + :title: Craftsman + :shredouts: + :E: A-10/U-2 + :L: F-15 + :M: F-16 +:2A3X4: + :name: Fighter Aircraft Integrated Avionics + :career_field: :2A + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 2A314 + :title: Helper + 3: + :code: 2A334 + :title: Apprentice + 5: + :code: 2A354 + :title: Journeyman + 7: + :code: 2A374 + :title: Craftsman + :shredouts: + :A: A-10/U-2Avionics + :B: F-15Avionics + :C: F-16Avionics +:2A3X5: + :name: Advanced Fighter Aircraft Integrated Avionics + :career_field: :2A + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 2A315 + :title: Helper + 3: + :code: 2A335 + :title: Apprentice + 5: + :code: 2A355 + :title: Journeyman + 7: + :code: 2A375 + :title: Craftsman + :shredouts: + :A: F-22 + :B: F-35 +:2A3X7: + :name: Tactical Aircraft Maintenance (5th Generation) + :career_field: :2A + :changed_date: '2021-10-31' + :skill_levels: + 1: + :code: 2A317 + :title: Helper + 3: + :code: 2A337 + :title: Apprentice + 5: + :code: 2A357 + :title: Journeyman + 7: + :code: 2A377 + :title: Craftsman + :shredouts: + :A: F-22 + :B: F-35 +:2A5X0: + :name: Airlift/Special Mission Aircraft Maintenance Superintendent + :career_field: :2A + :cem_code: 2A500 + :changed_date: '2024-10-31' + :skill_levels: + 9: + :code: 2A590 + :title: Superintendent + :shredouts: {} +:2A5X1: + :name: Airlift/Special Mission Aircraft Maintenance + :career_field: :2A + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 2A511 + :title: Helper + 3: + :code: 2A531 + :title: Apprentice + 5: + :code: 2A551 + :title: Journeyman + 7: + :code: 2A571 + :title: Craftsman + :shredouts: + :A: C-20/C-21/C-22/C-37/C-40/E-4/VC- 25 + :B: C-130/C-27J + :C: C-5 + :D: C-17 + :M: MQ-1/9 + :R: RQ-4 +:2A5X2: + :name: Helicopter/Tiltrotor Aircraft Maintenance + :career_field: :2A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2A512 + :title: Helper + 3: + :code: 2A532 + :title: Apprentice + 5: + :code: 2A552 + :title: Journeyman + 7: + :code: 2A572 + :title: Craftsman + :shredouts: + :B: H-60 + :D: CV-22 + :E: MHU-139 +:2A5X4: + :name: Refuel/Bomber Aircraft Maintenance + :career_field: :2A + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 2A514 + :title: Helper + 3: + :code: 2A534 + :title: Apprentice + 5: + :code: 2A554 + :title: Journeyman + 7: + :code: 2A574 + :title: Craftsman + :shredouts: + :A: Any C-135/E-3/E-8 + :B: KC-10 + :C: KC-46 + :D: B-52 + :E: B-1 + :F: B-2 + :H: B-21 +:2A6X0: + :name: Aircraft Accessories + :career_field: :2A + :changed_date: '2017-10-31' + :skill_levels: + 9: + :code: 2A690 + :title: Superintendent + :shredouts: {} +:2A6X1: + :name: Aerospace Propulsion + :career_field: :2A + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 2A611 + :title: Helper + 3: + :code: 2A631 + :title: Apprentice + 5: + :code: 2A651 + :title: Journeyman + 7: + :code: 2A671 + :title: Craftsman + 9: + :code: 2A691 + :title: Superintendent + :shredouts: {} +:2A6X2: + :name: Aerospace Ground Equipment + :career_field: :2A + :changed_date: '2019-04-30' + :skill_levels: + 1: + :code: 2A612 + :title: Helper + 3: + :code: 2A632 + :title: Apprentice + 5: + :code: 2A652 + :title: Journeyman + 7: + :code: 2A672 + :title: Craftsman + 9: + :code: 2A692 + :title: Superintendent + :shredouts: {} +:2A6X3: + :name: Aircrew Egress Systems + :career_field: :2A + :changed_date: '2012-01-31' + :skill_levels: + 1: + :code: 2A613 + :title: Helper + 3: + :code: 2A633 + :title: Apprentice + 5: + :code: 2A653 + :title: Journeyman + 7: + :code: 2A673 + :title: Craftsman + :shredouts: {} +:2A6X4: + :name: Aircraft Fuel Systems + :career_field: :2A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2A614 + :title: Helper + 3: + :code: 2A634 + :title: Apprentice + 5: + :code: 2A654 + :title: Journeyman + 7: + :code: 2A674 + :title: Craftsman + :shredouts: {} +:2A6X5: + :name: Aircraft Hydraulic Systems + :career_field: :2A + :changed_date: '2012-01-31' + :skill_levels: + 1: + :code: 2A615 + :title: Helper + 3: + :code: 2A635 + :title: Apprentice + 5: + :code: 2A655 + :title: Journeyman + 7: + :code: 2A675 + :title: Craftsman + :shredouts: {} +:2A6X6: + :name: Aircraft Electrical and Environmental Systems + :career_field: :2A + :changed_date: '2012-01-31' + :skill_levels: + 1: + :code: 2A616 + :title: Helper + 3: + :code: 2A636 + :title: Apprentice + 5: + :code: 2A656 + :title: Journeyman + 7: + :code: 2A676 + :title: Craftsman + :shredouts: {} +:2A7X0: + :name: Aircraft Fabrication + :career_field: :2A + :changed_date: '2024-04-30' + :skill_levels: + 9: + :code: 2A790 + :title: Superintendent + :shredouts: {} +:2A7X1: + :name: Aircraft Metals Technology + :career_field: :2A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2A711 + :title: Helper + 3: + :code: 2A731 + :title: Apprentice + 5: + :code: 2A751 + :title: Journeyman + 7: + :code: 2A771 + :title: Craftsman + :shredouts: {} +:2A7X2: + :name: Nondestructive Inspection + :career_field: :2A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2A712 + :title: Helper + 3: + :code: 2A732 + :title: Apprentice + 5: + :code: 2A752 + :title: Journeyman + 7: + :code: 2A772 + :title: Craftsman + :shredouts: {} +:2A7X3: + :name: Aircraft Structural Maintenance + :career_field: :2A + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2A713 + :title: Helper + 3: + :code: 2A733 + :title: Apprentice + 5: + :code: 2A753 + :title: Journeyman + 7: + :code: 2A773 + :title: Craftsman + :shredouts: {} +:2A9X4: + :name: Heavy Aircraft Integrated Avionics + :career_field: :2A + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2A914 + :title: Helper + 3: + :code: 2A934 + :title: Apprentice + 5: + :code: 2A954 + :title: Journeyman + 7: + :code: 2A974 + :title: Craftsman + :shredouts: {} +:2F0X1: + :name: Fuels + :career_field: :2F + :cem_code: 2F000 + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 2F011 + :title: Helper + 3: + :code: 2F031 + :title: Apprentice + 5: + :code: 2F051 + :title: Journeyman + 7: + :code: 2F071 + :title: Craftsman + 9: + :code: 2F091 + :title: Superintendent + :shredouts: {} +:2G0X1: + :name: Logistics Plans + :career_field: :2G + :cem_code: 2G000 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 2G011 + :title: Helper + 3: + :code: 2G031 + :title: Apprentice + 5: + :code: 2G051 + :title: Journeyman + 7: + :code: 2G071 + :title: Craftsman + 9: + :code: 2G091 + :title: Superintendent + :shredouts: {} +:2M0X0: + :name: Missile and Space Systems Maintenance + :career_field: :2M + :cem_code: 2M000 + :changed_date: '2019-10-31' + :skill_levels: + 9: + :code: 2M090 + :title: Superintendent + :shredouts: {} +:2M0X1: + :name: Missile and Space Systems Electronic Maintenance + :career_field: :2M + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2M011 + :title: Helper + 3: + :code: 2M031 + :title: Apprentice + 5: + :code: 2M051 + :title: Journeyman + 7: + :code: 2M071 + :title: Craftsman + :shredouts: + :A: ICBM + :B: ALCM +:2M0X2: + :name: Missile and Space Systems Maintenance + :career_field: :2M + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2M012 + :title: Helper + 3: + :code: 2M032 + :title: Apprentice + 5: + :code: 2M052 + :title: Journeyman + 7: + :code: 2M072 + :title: Craftsman + :shredouts: {} +:2M0X3: + :name: Missile and Space Facilities + :career_field: :2M + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2M013 + :title: Helper + 3: + :code: 2M033 + :title: Apprentice + 5: + :code: 2M053 + :title: Journeyman + 7: + :code: 2M073 + :title: Craftsman + :shredouts: {} +:2P0X1: + :name: Precision Measurement Equipment Laboratory + :career_field: :2P + :cem_code: 2P000 + :changed_date: '2014-10-31' + :skill_levels: + 1: + :code: 2P011 + :title: Helper + 3: + :code: 2P031 + :title: Apprentice + 5: + :code: 2P051 + :title: Journeyman + 7: + :code: 2P071 + :title: Craftsman + 9: + :code: 2P091 + :title: Superintendent + :shredouts: {} +:2R2X1: + :name: Maintenance Management + :career_field: :2R + :cem_code: 2R200 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2R211 + :title: Entry + 3: + :code: 2R231 + :title: Apprentice + 5: + :code: 2R251 + :title: Journeyman + 7: + :code: 2R271 + :title: Craftsman + 9: + :code: 2R291 + :title: Superintendent + :shredouts: {} +:2S0X1: + :name: Materiel Management + :career_field: :2S + :cem_code: 2S000 + :changed_date: '2022-04-30' + :skill_levels: + 1: + :code: 2S011 + :title: Helper + 3: + :code: 2S031 + :title: Apprentice + 5: + :code: 2S051 + :title: Journeyman + 7: + :code: 2S071 + :title: Craftsman + 9: + :code: 2S091 + :title: Superintendent + :shredouts: {} +:2T0X1: + :name: Traffic Management Operations + :career_field: :2T + :cem_code: 2T000 + :changed_date: '2022-04-30' + :skill_levels: + 1: + :code: 2T011 + :title: Helper + 3: + :code: 2T031 + :title: Apprentice + 5: + :code: 2T051 + :title: Journeyman + 7: + :code: 2T071 + :title: Craftsman + 9: + :code: 2T091 + :title: Superintendent + :shredouts: {} +:2T1X1: + :name: Ground Transportation + :career_field: :2T + :cem_code: 2T100 + :changed_date: '2020-04-30' + :skill_levels: + 1: + :code: 2T111 + :title: Helper + 3: + :code: 2T131 + :title: Apprentice + 5: + :code: 2T151 + :title: Journeyman + 7: + :code: 2T171 + :title: Craftsman + 9: + :code: 2T191 + :title: Superintendent + :shredouts: {} +:2T2X1: + :name: Air Transportation + :career_field: :2T + :cem_code: 2T200 + :changed_date: '2022-04-30' + :skill_levels: + 1: + :code: 2T211 + :title: Helper + 3: + :code: 2T231 + :title: Apprentice + 5: + :code: 2T251 + :title: Journeyman + 7: + :code: 2T271 + :title: Craftsman + 9: + :code: 2T291 + :title: Superintendent + :shredouts: {} +:2T3X0: + :name: Vehicle Management + :career_field: :2T + :cem_code: 2T300 + :skill_levels: + 9: + :code: 2T390 + :title: Superintendent + :shredouts: {} +:2T3X1: + :name: Mission Generation Vehicular Equipment Maintenance + :career_field: :2T + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 2T311 + :title: Helper + 3: + :code: 2T331 + :title: Apprentice + 5: + :code: 2T351 + :title: Journeyman + 7: + :code: 2T371 + :title: Craftsman + :shredouts: {} +:2T3X7: + :name: Fleet Management and Analysis + :career_field: :2T + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2T317 + :title: Helper + 3: + :code: 2T337 + :title: Apprentice + 5: + :code: 2T357 + :title: Journeyman + 7: + :code: 2T377 + :title: Craftsman + :shredouts: {} +:2W0X1: + :name: Munitions Systems + :career_field: :2W + :cem_code: 2W000 + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 2W011 + :title: Helper + 3: + :code: 2W031 + :title: Apprentice + 5: + :code: 2W051 + :title: Journeyman + 7: + :code: 2W071 + :title: Craftsman + 9: + :code: 2W091 + :title: Superintendent + :shredouts: {} +:2W1X1: + :name: Aircraft Armament Systems + :career_field: :2W + :cem_code: 2W100 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2W111 + :title: Helper + 3: + :code: 2W131 + :title: Apprentice + 5: + :code: 2W151 + :title: Journeyman + 7: + :code: 2W171 + :title: Craftsman + 9: + :code: 2W191 + :title: Superintendent + :shredouts: + :C: A-10 + :E: F-15 + :F: F-16 + :J: F-35 + :K: B-52/B-2 + :L: B-1 + :N: F-22 + :Q: RPA(MQ-1/MQ-9) + :Z: All Other +:2W2X1: + :name: Nuclear Weapons + :career_field: :2W + :cem_code: 2W200 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 2W211 + :title: Helper + 3: + :code: 2W231 + :title: Apprentice + 5: + :code: 2W251 + :title: Journeyman + 7: + :code: 2W271 + :title: Craftsman + 9: + :code: 2W291 + :title: Superintendent + :shredouts: {} +:3E0X0: + :name: Facility Systems + :career_field: :3E + :cem_code: 3E000 + :changed_date: '2025-04-30' + :skill_levels: + 9: + :code: 3E090 + :title: Superintendent + :shredouts: {} +:3E0X1: + :name: Electrical Systems + :career_field: :3E + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E011 + :title: Helper + 3: + :code: 3E031 + :title: Apprentice + 5: + :code: 3E051 + :title: Journeyman + 7: + :code: 3E071 + :title: Craftsman + :shredouts: {} +:3E0X2: + :name: Electrical Power Production + :career_field: :3E + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E012 + :title: Helper + 3: + :code: 3E032 + :title: Apprentice + 5: + :code: 3E052 + :title: Journeyman + 7: + :code: 3E072 + :title: Craftsman + :shredouts: {} +:3E1X1: + :name: Heating, Ventilation, Air Conditioning, and Refrigeration + :career_field: :3E + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E111 + :title: Helper + 3: + :code: 3E131 + :title: Apprentice + 5: + :code: 3E151 + :title: Journeyman + 7: + :code: 3E171 + :title: Craftsman + :shredouts: {} +:3E2X0: + :name: Heavy Repair + :career_field: :3E + :cem_code: 3E000 + :changed_date: '2025-04-30' + :skill_levels: + 9: + :code: 3E290 + :title: Superintendent + :shredouts: {} +:3E2X1: + :name: Pavements and Construction Equipment + :career_field: :3E + :skill_levels: + 1: + :code: 3E211 + :title: Helper + 3: + :code: 3E231 + :title: Apprentice + 5: + :code: 3E251 + :title: Journeyman + 7: + :code: 3E271 + :title: Craftsman + :shredouts: {} +:3E3X1: + :name: Structural + :career_field: :3E + :skill_levels: + 1: + :code: 3E311 + :title: Helper + 3: + :code: 3E331 + :title: Apprentice + 5: + :code: 3E351 + :title: Journeyman + 7: + :code: 3E371 + :title: Craftsman + :shredouts: {} +:3E4X0: + :name: Infrastructure Systems + :career_field: :3E + :cem_code: 3E000 + :changed_date: '2025-04-30' + :skill_levels: + 9: + :code: 3E490 + :title: Superintendent + :shredouts: {} +:3E4X1: + :name: Water and Fuel Systems Maintenance + :career_field: :3E + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E411 + :title: Helper + 3: + :code: 3E431 + :title: Apprentice + 5: + :code: 3E451 + :title: Journeyman + 7: + :code: 3E471 + :title: Craftsman + :shredouts: + :A: Fuel Systems Maintenance +:3E4X3: + :name: Pest Management + :career_field: :3E + :skill_levels: + 1: + :code: 3E413 + :title: Helper + 3: + :code: 3E433 + :title: Apprentice + 5: + :code: 3E453 + :title: Journeyman + 7: + :code: 3E473 + :title: Craftsman + :shredouts: {} +:3E5X1: + :name: Engineering + :career_field: :3E + :cem_code: 3E000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E511 + :title: Helper + 3: + :code: 3E531 + :title: Apprentice + 5: + :code: 3E551 + :title: Journeyman + 7: + :code: 3E571 + :title: Craftsman + 9: + :code: 3E591 + :title: Superintendent + :shredouts: {} +:3E6X1: + :name: Operations Management + :career_field: :3E + :cem_code: 3E000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3E611 + :title: Helper + 3: + :code: 3E631 + :title: Apprentice + 5: + :code: 3E651 + :title: Journeyman + 7: + :code: 3E671 + :title: Craftsman + 9: + :code: 3E691 + :title: Superintendent + :shredouts: {} +:3E7X1: + :name: Fire Protection + :career_field: :3E + :cem_code: 3E700 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 3E711 + :title: Helper + 3: + :code: 3E731 + :title: Apprentice + 5: + :code: 3E751 + :title: Journeyman + 7: + :code: 3E771 + :title: Craftsman + 9: + :code: 3E791 + :title: Superintendent + :shredouts: {} +:3E8X1: + :name: Explosive Ordnance Disposal + :career_field: :3E + :cem_code: 3E800 + :changed_date: '2023-10-31' + :skill_levels: + 1: + :code: 3E811 + :title: Helper + 3: + :code: 3E831 + :title: Apprentice + 5: + :code: 3E851 + :title: Journeyman + 7: + :code: 3E871 + :title: Craftsman + 9: + :code: 3E891 + :title: Superintendent + :shredouts: {} +:3E9X1: + :name: Emergency Management + :career_field: :3E + :cem_code: 3E900 + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 3E911 + :title: Helper + 3: + :code: 3E931 + :title: Apprentice + 5: + :code: 3E951 + :title: Journeyman + 7: + :code: 3E971 + :title: Craftsman + 9: + :code: 3E991 + :title: Superintendent + :shredouts: {} +:3F0X1: + :name: Human Resources and Administration + :career_field: :3F + :cem_code: 3F000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 3F011 + :title: Helper + 3: + :code: 3F031 + :title: Apprentice + 5: + :code: 3F051 + :title: Journeyman + 7: + :code: 3F071 + :title: Craftsman + 9: + :code: 3F091 + :title: Superintendent + :shredouts: {} +:3F1X1: + :name: Services + :career_field: :3F + :cem_code: 3F100 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 3F111 + :title: Helper + 3: + :code: 3F131 + :title: Apprentice + 5: + :code: 3F151 + :title: Journeyman + 7: + :code: 3F171 + :title: Craftsman + 9: + :code: 3F191 + :title: Superintendent + :shredouts: {} +:3F2X1: + :name: Education and Training + :career_field: :3F + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3F211 + :title: Helper + 3: + :code: 3F231 + :title: Apprentice + 5: + :code: 3F251 + :title: Journeyman + 7: + :code: 3F271 + :title: Craftsman + 9: + :code: 3F291 + :title: Superintendent + :shredouts: {} +:3F3X1: + :name: Manpower + :career_field: :3F + :cem_code: 3F300 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 3F311 + :title: Helper + 3: + :code: 3F331 + :title: Apprentice + 5: + :code: 3F351 + :title: Journeyman + 7: + :code: 3F371 + :title: Craftsman + 9: + :code: 3F391 + :title: Superintendent + :shredouts: + :M: Management Engineering/DataAnalytics +:3F4X1: + :name: Equal Opportunity + :career_field: :3F + :cem_code: 3F400 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3F411 + :title: Helper + 3: + :code: 3F431 + :title: Journeyman + 7: + :code: 3F471 + :title: Craftsman + 9: + :code: 3F491 + :title: Superintendent + :shredouts: {} +:3G0X1: + :name: Talent Acquisition + :career_field: :3G + :cem_code: 3G000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3G011 + :title: Helper + 3: + :code: 3G031 + :title: Apprentice + 5: + :code: 3G051 + :title: Journeyman + 7: + :code: 3G071 + :title: Craftsman + 9: + :code: 3G091 + :title: Superintendent + :shredouts: {} +:3H0X1: + :name: Historian + :career_field: :3H + :changed_date: '2020-10-31' + :skill_levels: + 1: + :code: 3H011 + :title: Helper + 3: + :code: 3H031 + :title: Apprentice + 5: + :code: 3H051 + :title: Journeyman + 7: + :code: 3H071 + :title: Craftsman + 9: + :code: 3H091 + :title: Superintendent + :shredouts: {} +:3N0X0: + :name: Public Affairs + :career_field: :3N + :cem_code: 3N000 + :changed_date: '2025-04-30' + :skill_levels: + 9: + :code: 3N090 + :title: Superintendent + :shredouts: {} +:3N0X6: + :name: Public Affairs + :career_field: :3N + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 3N016 + :title: Helper + 3: + :code: 3N036 + :title: Apprentice + 5: + :code: 3N056 + :title: Journeyman + 7: + :code: 3N076 + :title: Craftsman + :shredouts: {} +:3N1X1: + :name: Regional Band + :career_field: :3N + :cem_code: 3N100 + :changed_date: '2023-04-30' + :skill_levels: + 1: + :code: 3N111 + :title: Helper + 3: + :code: 3N131 + :title: Apprentice + 5: + :code: 3N151 + :title: Journeyman + 7: + :code: 3N171 + :title: Craftsman + 9: + :code: 3N191 + :title: Superintendent + :shredouts: + :A: Clarinet + :B: Saxophone + :C: Bassoon + :D: Oboe + :E: Flute + :F: Horn + :G: Trumpet + :H: Euphonium + :J: Trombone + :K: Tuba + :L: Percussion + :M: Piano + :N: Guitar + :P: Arranger + :Q: Jazz Trumpet + :R: Vocalist + :S: String/Electric Bass + :U: Drum Set + :V: Audio Engineer +:3N2X1: + :name: Premier Band - The USAF Band + :career_field: :3N + :cem_code: 3N200 + :changed_date: '2020-04-30' + :skill_levels: + 5: + :code: 3N251 + :title: Journeyman + 7: + :code: 3N271 + :title: Craftsman + 9: + :code: 3N291 + :title: Superintendent + :shredouts: {} +:3N3X1: + :name: Premier Band - The USAF Academy Band + :career_field: :3N + :cem_code: 3N300 + :changed_date: '2020-04-30' + :skill_levels: + 5: + :code: 3N351 + :title: Journeyman + 7: + :code: 3N371 + :title: Craftsman + 9: + :code: 3N391 + :title: Superintendent + :shredouts: {} +:3P0X1: + :name: Security Forces + :career_field: :3P + :cem_code: 3P000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 3P011 + :title: Helper + 3: + :code: 3P031 + :title: Apprentice + 5: + :code: 3P051 + :title: Journeyman + 7: + :code: 3P071 + :title: Craftsman + 9: + :code: 3P091 + :title: Superintendent + :shredouts: + :A: Military Working Dog Handler + :B: CombatArms +:4A0X1: + :name: Health Services Management + :career_field: :4A + :cem_code: 4A000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 4A011 + :title: Helper + 3: + :code: 4A031 + :title: Apprentice + 5: + :code: 4A051 + :title: Journeyman + 7: + :code: 4A071 + :title: Craftsman + 9: + :code: 4A091 + :title: Superintendent + :shredouts: + :S: Health Information Technology +:4A1X1: + :name: Medical Materiel + :career_field: :4A + :cem_code: 4A100 + :skill_levels: + 1: + :code: 4A111 + :title: Helper + 3: + :code: 4A131 + :title: Apprentice + 5: + :code: 4A151 + :title: Journeyman + 7: + :code: 4A171 + :title: Craftsman + 9: + :code: 4A191 + :title: Superintendent + :shredouts: {} +:4A2X1: + :name: Biomedical Equipment + :career_field: :4A + :cem_code: 4A200 + :changed_date: '2024-04-30' + :skill_levels: + 1: + :code: 4A211 + :title: Helper + 3: + :code: 4A231 + :title: Apprentice + 5: + :code: 4A251 + :title: Journeyman + 7: + :code: 4A271 + :title: Craftsman + 9: + :code: 4A291 + :title: Superintendent + :shredouts: {} +:4B0X1: + :name: Bioenvironmental Engineering (BE) + :career_field: :4B + :cem_code: 4B000 + :changed_date: '2016-10-31' + :skill_levels: + 1: + :code: 4B011 + :title: Helper + 3: + :code: 4B031 + :title: Apprentice + 5: + :code: 4B051 + :title: Journeyman + 7: + :code: 4B071 + :title: Craftsman + 9: + :code: 4B091 + :title: Superintendent + :shredouts: {} +:4C0X1: + :name: Mental Health Service + :career_field: :4C + :cem_code: 4C000 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 4C011 + :title: Helper + 3: + :code: 4C031 + :title: Apprentice + 5: + :code: 4C051 + :title: Journeyman + 7: + :code: 4C071 + :title: Craftsman + 9: + :code: 4C091 + :title: Superintendent + :shredouts: {} +:4D0X1: + :name: Diet Therapy + :career_field: :4D + :cem_code: 4D000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 4D011 + :title: Helper + 3: + :code: 4D031 + :title: Apprentice + 5: + :code: 4D051 + :title: Journeyman + 7: + :code: 4D071 + :title: Craftsman + 9: + :code: 4D091 + :title: Superintendent + :shredouts: {} +:4E0X1: + :name: Public Health + :career_field: :4E + :cem_code: 4E000 + :changed_date: '2021-04-30' + :skill_levels: + 1: + :code: 4E011 + :title: Helper + 3: + :code: 4E031 + :title: Apprentice + 5: + :code: 4E051 + :title: Journeyman + 7: + :code: 4E071 + :title: Craftsman + 9: + :code: 4E091 + :title: Superintendent + :shredouts: {} +:4H0X1: + :name: Respiratory Care Practitioner + :career_field: :4H + :cem_code: 4H000 + :skill_levels: + 1: + :code: 4H011 + :title: Helper + 3: + :code: 4H031 + :title: Apprentice + 5: + :code: 4H051 + :title: Journeyman + 7: + :code: 4H071 + :title: Craftsman + 9: + :code: 4H091 + :title: Superintendent + :shredouts: {} +:4J0X2: + :name: Physical Medicine + :career_field: :4J + :cem_code: 4J000 + :changed_date: '2018-04-30' + :skill_levels: + 1: + :code: 4J012 + :title: Helper + 3: + :code: 4J032 + :title: Apprentice + 5: + :code: 4J052 + :title: Journeyman + 7: + :code: 4J072 + :title: Craftsman + 9: + :code: 4J090 + :title: Superintendent + :shredouts: + :A: Orthotic +:4N0X1: + :name: Aerospace Medical Service + :career_field: :4N + :cem_code: 4N000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 4N011 + :title: Helper + 3: + :code: 4N031 + :title: Apprentice + 5: + :code: 4N051 + :title: Journeyman + 7: + :code: 4N071 + :title: Craftsman + 9: + :code: 4N091 + :title: Superintendent + :shredouts: + :B: Neurodiagnostic Medical Technician + :C: Independent Duty Medical Technician + :D: Allergy/Immunization Technician + :F: Flight and Operational Medical Technician + :G: Aeromedical Evacuation Technician + :H: National Registry Paramedic +:4N1X1: + :name: Surgical Technologist + :career_field: :4N + :cem_code: 4N000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 4N111 + :title: Helper + 3: + :code: 4N131 + :title: Apprentice + 5: + :code: 4N151 + :title: Journeyman + 7: + :code: 4N171 + :title: Craftsman + 9: + :code: 4N191 + :title: Superintendent + :shredouts: + :B: Urology + :C: Orthopedics + :D: Otolaryngology +:4P0X1: + :name: Pharmacy + :career_field: :4P + :cem_code: 4P000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 4P011 + :title: Helper + 3: + :code: 4P031 + :title: Apprentice + 5: + :code: 4P051 + :title: Journeyman + 7: + :code: 4P071 + :title: Craftsman + 9: + :code: 4P091 + :title: Superintendent + :shredouts: {} +:4R0X1: + :name: Diagnostic Imaging + :career_field: :4R + :cem_code: 4R000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 4R011 + :title: Helper + 3: + :code: 4R031 + :title: Apprentice + 5: + :code: 4R051 + :title: Journeyman + 7: + :code: 4R071 + :title: Craftsman + 9: + :code: 4R090 + :title: Superintendent + :shredouts: + :A: Nuclear Medicine + :B: Diagnostic Medical Sonography + :C: Magnetic Resonance Imaging + :D: Mammography + :E: Interventional Radiography + :F: Computed Tomography +:4T0X0: + :name: Medical Laboratory + :career_field: :4T + :cem_code: 4T000 + :skill_levels: + 9: + :code: 4T090 + :title: Superintendent + :shredouts: {} +:4T0X1: + :name: Medical Laboratory + :career_field: :4T + :skill_levels: + 1: + :code: 4T011 + :title: Helper + 3: + :code: 4T031 + :title: Apprentice + 5: + :code: 4T051 + :title: Journeyman + 7: + :code: 4T071 + :title: Craftsman + :shredouts: {} +:4T0X2: + :name: Histopathology + :career_field: :4T + :changed_date: '2021-04-30' + :skill_levels: + 1: + :code: 4T012 + :title: Helper + 3: + :code: 4T032 + :title: Apprentice + 5: + :code: 4T052 + :title: Journeyman + 7: + :code: 4T072 + :title: Craftsman + :shredouts: {} +:4V0X1: + :name: Ophthalmic + :career_field: :4V + :cem_code: 4V000 + :changed_date: '2024-10-31' + :skill_levels: + 1: + :code: 4V011 + :title: Helper + 3: + :code: 4V031 + :title: Apprentice + 5: + :code: 4V051 + :title: Journeyman + 7: + :code: 4V071 + :title: Craftsman + 9: + :code: 4V091 + :title: Superintendent + :shredouts: + :S: Ophthalmology +:4Y0X0: + :name: Dental + :career_field: :4Y + :cem_code: 4Y000 + :skill_levels: + 9: + :code: 4Y090 + :title: Superintendent + :shredouts: {} +:4Y0X1: + :name: Dental Assistant + :career_field: :4Y + :skill_levels: + 1: + :code: 4Y011 + :title: Helper + 3: + :code: 4Y031 + :title: Apprentice + 5: + :code: 4Y051 + :title: Journeyman + 7: + :code: 4Y071 + :title: Craftsman + :shredouts: + :H: Dental Hygienist +:4Y0X2: + :name: Dental Laboratory + :career_field: :4Y + :changed_date: '2012-01-31' + :skill_levels: + 1: + :code: 4Y012 + :title: Helper + 3: + :code: 4Y032 + :title: Apprentice + 5: + :code: 4Y052 + :title: Journeyman + 7: + :code: 4Y072 + :title: Craftsman + :shredouts: {} +:5J0X1: + :name: Paralegal + :career_field: :5J + :cem_code: 5J000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 5J011 + :title: Helper + 3: + :code: 5J031 + :title: Apprentice + 5: + :code: 5J051 + :title: Journeyman + 7: + :code: 5J071 + :title: Craftsman + 9: + :code: 5J091 + :title: Superintendent + :shredouts: {} +:5R0X1: + :name: Religious Affairs + :career_field: :5R + :cem_code: 5R000 + :changed_date: '2025-04-30' + :skill_levels: + 1: + :code: 5R011 + :title: Helper + 3: + :code: 5R031 + :title: Apprentice + 5: + :code: 5R051 + :title: Journeyman + 7: + :code: 5R071 + :title: Craftsman + 9: + :code: 5R091 + :title: Superintendent + :shredouts: {} +:6C0X1: + :name: Contracting + :career_field: :6C + :cem_code: 6C000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 6C011 + :title: Helper + 3: + :code: 6C031 + :title: Apprentice + 5: + :code: 6C051 + :title: Journeyman + 7: + :code: 6C071 + :title: Craftsman + 9: + :code: 6C091 + :title: Senior Enlisted Leader + :shredouts: {} +:6F0X1: + :name: Financial Management and Comptroller + :career_field: :6F + :cem_code: 6F000 + :changed_date: '2021-10-31' + :skill_levels: + 1: + :code: 6F011 + :title: Helper + 3: + :code: 6F031 + :title: Apprentice + 5: + :code: 6F051 + :title: Journeyman + 7: + :code: 6F071 + :title: Craftsman + 9: + :code: 6F091 + :title: Superintendent + :shredouts: {} +:7S0X1: + :name: Special Investigations + :career_field: :7S + :cem_code: 7S000 + :changed_date: '2022-10-31' + :skill_levels: + 1: + :code: 7S011 + :title: Helper + 3: + :code: 7S031 + :title: Journeyman + 7: + :code: 7S071 + :title: Craftsman + 9: + :code: 7S091 + :title: Superintendent + :shredouts: {} +:8G0X1: + :name: Premier Honor Guard + :career_field: :8G + :cem_code: 8G000 + :changed_date: '2025-10-31' + :skill_levels: + 1: + :code: 8G011 + :title: Helper + 3: + :code: 8G031 + :title: Apprentice + 5: + :code: 8G051 + :title: Journeyman + 7: + :code: 8G071 + :title: Craftsman + 9: + :code: 8G091 + :title: Superintendent + :shredouts: + :B: Pallbearer + :C: Color Guard + :D: Drill Team diff --git a/test/gov_codes/afsc/enlisted_concrete_test.rb b/test/gov_codes/afsc/enlisted_concrete_test.rb new file mode 100644 index 0000000..bd002ef --- /dev/null +++ b/test/gov_codes/afsc/enlisted_concrete_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" + +module GovCodes + module AFSC + describe Enlisted::Parser do + it "parses a concrete skill-level code" do + result = Enlisted::Parser.new("1A172").parse + _(result[:career_field]).must_equal :"1A" + _(result[:career_field_subdivision]).must_equal :"1A1" + _(result[:skill_level]).must_equal :"7" + _(result[:skill_level_number]).must_equal 7 + _(result[:skill_level_name]).must_equal "Craftsman" + _(result[:specialty]).must_equal :"1A1X2" + _(result[:subcategory]).must_equal :"1X2" + _(result[:specific_afsc]).must_equal :"1A172" + end + + it "still parses the generic X-form unchanged" do + result = Enlisted::Parser.new("1A1X2").parse + _(result[:skill_level]).must_equal :X + _(result[:skill_level_number]).must_be_nil + _(result[:skill_level_name]).must_be_nil + _(result[:specialty]).must_equal :"1A1X2" + _(result[:subcategory]).must_equal :"1X2" + _(result[:specific_afsc]).must_equal :"1A1X2" + end + + it "parses a concrete code with a shredout" do + result = Enlisted::Parser.new("1A172Y").parse + _(result[:skill_level_number]).must_equal 7 + _(result[:shredout]).must_equal :Y + _(result[:specialty]).must_equal :"1A1X2" + end + end + + describe "Enlisted.find with concrete codes" do + it "resolves a concrete skill-level code to specialty details" do + code = Enlisted.find("1A172") + _(code).wont_be_nil + _(code.specialty).must_equal :"1A1X2" + _(code.specialty_name).must_equal "Mobility Force Aviator" + _(code.skill_level_number).must_equal 7 + _(code.skill_level_name).must_equal "Craftsman" + _(code.specific_afsc).must_equal :"1A172" + end + + it "keeps name and skill_level backward compatible for the generic form" do + code = Enlisted.find("1A1X2") + _(code.name).must_equal "Mobility Force Aviator" + _(code.skill_level).must_equal :X + _(code.specialty_name).must_equal "Mobility Force Aviator" + end + + it "exposes shredout_name distinctly from specialty_name" do + code = Enlisted.find("1A1X2A") + _(code.specialty_name).must_equal "Mobility Force Aviator" + _(code.shredout).must_equal :A + _(code.shredout_name).must_equal "C-5 Flight Engineer" + _(code.name).must_equal "C-5 Flight Engineer" + end + + it "resolves a concrete code combined with a real shredout" do + code = Enlisted.find("1A172A") + _(code.skill_level_number).must_equal 7 + _(code.skill_level_name).must_equal "Craftsman" + _(code.specialty).must_equal :"1A1X2" + _(code.specialty_name).must_equal "Mobility Force Aviator" + _(code.shredout).must_equal :A + _(code.shredout_name).must_equal "C-5 Flight Engineer" + _(code.name).must_equal "C-5 Flight Engineer" # deepest + end + + it "falls back to the specialty when the shredout is not in the data" do + # R is not a documented shredout for 1A1X2 in the DAFECD release. + code = Enlisted.find("1A172R") + _(code.shredout).must_equal :R + _(code.shredout_name).must_be_nil + _(code.name).must_equal "Mobility Force Aviator" + _(code.skill_level_number).must_equal 7 + end + end + end +end diff --git a/test/gov_codes/afsc/enlisted_coverage_test.rb b/test/gov_codes/afsc/enlisted_coverage_test.rb index 27cb836..6cf98ed 100644 --- a/test/gov_codes/afsc/enlisted_coverage_test.rb +++ b/test/gov_codes/afsc/enlisted_coverage_test.rb @@ -24,10 +24,10 @@ def test_find_with_partial_codes def test_parser_with_various_inputs parser = GovCodes::AFSC::Enlisted::Parser.new(nil) - assert_equal({prefix: nil, career_group: nil, career_field: nil, career_field_subdivision: nil, skill_level: nil, specific_afsc: nil, subcategory: nil, shredout: nil}, parser.parse) + assert_equal({prefix: nil, career_group: nil, career_field: nil, career_field_subdivision: nil, skill_level: nil, skill_level_number: nil, skill_level_name: nil, specialty: nil, specialty_name: nil, specific_afsc: nil, subcategory: nil, shredout: nil}, parser.parse) parser = GovCodes::AFSC::Enlisted::Parser.new("") - assert_equal({prefix: nil, career_group: nil, career_field: nil, career_field_subdivision: nil, skill_level: nil, specific_afsc: nil, subcategory: nil, shredout: nil}, parser.parse) + assert_equal({prefix: nil, career_group: nil, career_field: nil, career_field_subdivision: nil, skill_level: nil, skill_level_number: nil, skill_level_name: nil, specialty: nil, specialty_name: nil, specific_afsc: nil, subcategory: nil, shredout: nil}, parser.parse) parser = GovCodes::AFSC::Enlisted::Parser.new("1A1X2") result = parser.parse @@ -90,175 +90,19 @@ def test_parser_not_at_end assert_equal :"1A1X2", result[:specific_afsc] end - def test_find_name_recursive_with_missing_keys - # nil career_field - result = {career_field: nil} - assert_equal "Unknown", GovCodes::AFSC::Enlisted.find_name_recursive(result) - # unknown career_field - result = {career_field: :ZZ} - assert_equal "Unknown", GovCodes::AFSC::Enlisted.find_name_recursive(result) - # valid career_field, missing subcategory - result = {career_field: :"1A", subcategory: :ZZZ} - assert_equal "Aircrew operations", GovCodes::AFSC::Enlisted.find_name_recursive(result) - # valid career_field, valid subcategory, missing shredout - result = {career_field: :"1A", subcategory: :"1X2", shredout: :Z} - # If Z exists, should return its name, else fallback - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - assert name.is_a?(String) - end - - def test_find_name_recursive_with_real_data - # Full path - result = {career_field: :"1A", subcategory: :"1X2", shredout: :A} - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - # The actual name depends on the loaded data - could be "C-5 flight engineer" or "Test" - assert name.is_a?(String) - assert !name.empty? - - # Only subcategory - result = {career_field: :"1A", subcategory: :"1X2"} - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - # The actual name depends on the loaded data - could be "Mobility force aviator" or "Test" - assert name.is_a?(String) - assert !name.empty? - - # Only career_field - result = {career_field: :"1A"} - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - # The actual name depends on the loaded data - could be "Aircrew operations" or "Test" - assert name.is_a?(String) - assert !name.empty? - end - - def test_find_name_recursive_with_nil_subcategories - result = {career_field: :"1A", subcategory: :"1X2"} - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - assert name.is_a?(String) - end - - def test_find_name_recursive_with_nil_shredout - result = {career_field: :"1A", subcategory: :"1X2", shredout: nil} - name = GovCodes::AFSC::Enlisted.find_name_recursive(result) - assert_equal "Mobility force aviator", name - end - - def test_data_loader_handles_invalid_yaml - require "tmpdir" - require "fileutils" - temp_dir = Dir.mktmpdir - begin - gov_codes_dir = File.join(temp_dir, "gov_codes", "afsc") - FileUtils.mkdir_p(gov_codes_dir) - File.write(File.join(gov_codes_dir, "enlisted.yml"), "invalid: yaml: content:") - data = GovCodes::AFSC::Enlisted.data(lookup: [temp_dir]) - assert_kind_of Hash, data - # The data will contain real YAML data from the gem's lib directory - # plus any valid data from the custom lookup path - assert data.key?(:"1A") # Real data from gem - ensure - FileUtils.rm_rf(temp_dir) - end - end - - def test_data_loader_handles_empty_yaml - require "tmpdir" - require "fileutils" - temp_dir = Dir.mktmpdir - begin - gov_codes_dir = File.join(temp_dir, "gov_codes", "afsc") - FileUtils.mkdir_p(gov_codes_dir) - File.write(File.join(gov_codes_dir, "enlisted.yml"), "") - data = GovCodes::AFSC::Enlisted.data(lookup: [temp_dir]) - assert_kind_of Hash, data - # The data will contain real YAML data from the gem's lib directory - assert data.key?(:"1A") # Real data from gem - ensure - FileUtils.rm_rf(temp_dir) - end - end - - def test_data_loader_handles_nil_lookup - data = GovCodes::AFSC::Enlisted.data(lookup: nil) - assert_kind_of Hash, data - assert data.empty? - end - - def test_data_loader_handles_empty_lookup - data = GovCodes::AFSC::Enlisted.data(lookup: []) - assert_kind_of Hash, data - assert data.empty? - end - - def test_data_loader_handles_nonexistent_directory - data = GovCodes::AFSC::Enlisted.data(lookup: ["/nonexistent/directory"]) - assert_kind_of Hash, data - # The data will contain real YAML data from the gem's lib directory - assert data.key?(:"1A") # Real data from gem - end - - def test_data_loader_with_multiple_files - require "tmpdir" - require "fileutils" - temp_dir1 = Dir.mktmpdir - temp_dir2 = Dir.mktmpdir - begin - gov_codes_dir1 = File.join(temp_dir1, "gov_codes", "afsc") - gov_codes_dir2 = File.join(temp_dir2, "gov_codes", "afsc") - FileUtils.mkdir_p(gov_codes_dir1) - FileUtils.mkdir_p(gov_codes_dir2) - File.write(File.join(gov_codes_dir1, "enlisted.yml"), "1A:\n name: Test1") - File.write(File.join(gov_codes_dir2, "enlisted.yml"), "1B:\n name: Test2") - data = GovCodes::AFSC::Enlisted.data(lookup: [temp_dir1, temp_dir2]) - assert_equal "Test1", data[:"1A"][:name] - assert_equal "Test2", data[:"1B"][:name] - ensure - FileUtils.rm_rf(temp_dir1) - FileUtils.rm_rf(temp_dir2) - end - end - - def test_reset_data_method - # Don't actually call reset_data as it affects global state - # Just test that the method exists and can be called - assert_respond_to GovCodes::AFSC::Enlisted, :reset_data - # Test that DATA is accessible - assert_kind_of Hash, GovCodes::AFSC::Enlisted::DATA - assert GovCodes::AFSC::Enlisted::DATA.key?(:"1A") - end - - def test_reset_data_with_custom_lookup - require "tmpdir" - require "fileutils" - temp_dir = Dir.mktmpdir - begin - gov_codes_dir = File.join(temp_dir, "gov_codes", "afsc") - FileUtils.mkdir_p(gov_codes_dir) - File.write(File.join(gov_codes_dir, "enlisted.yml"), "1A:\n name: Test") - - # Test the data method directly instead of calling reset_data - data = GovCodes::AFSC::Enlisted.data(lookup: [temp_dir]) - assert_equal "Test", data[:"1A"][:name] - - # Verify that the global DATA is not affected - refute_equal "Test", GovCodes::AFSC::Enlisted::DATA[:"1A"][:name] - ensure - FileUtils.rm_rf(temp_dir) - end - end - def test_valid_enlisted_codes - # Test various valid enlisted codes + # Test various valid enlisted codes against the shipped DAFECD release. code1 = GovCodes::AFSC::Enlisted.find("1A1X2") assert code1 - assert_equal "Mobility force aviator", code1.name + assert_equal "Mobility Force Aviator", code1.name code2 = GovCodes::AFSC::Enlisted.find("1A1X2A") assert code2 - assert_equal "C-5 flight engineer", code2.name + assert_equal "C-5 Flight Engineer", code2.name code3 = GovCodes::AFSC::Enlisted.find("A1A1X2A") assert code3 - assert_equal "C-5 flight engineer", code3.name + assert_equal "C-5 Flight Engineer", code3.name end def test_code_object_attributes @@ -274,6 +118,6 @@ def test_code_object_attributes assert_equal :"1A1X2", code.specific_afsc assert_equal :"1X2", code.subcategory assert_equal :A, code.shredout - assert_equal "C-5 flight engineer", code.name + assert_equal "C-5 Flight Engineer", code.name end end diff --git a/test/gov_codes/afsc/enlisted_release_test.rb b/test/gov_codes/afsc/enlisted_release_test.rb new file mode 100644 index 0000000..08e6550 --- /dev/null +++ b/test/gov_codes/afsc/enlisted_release_test.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" +require "fileutils" +require "date" +require "gov_codes/afsc/enlisted" +require "gov_codes/afsc/releases" + +module GovCodes + module AFSC + describe "Enlisted versioned lookup" do + before do + @temp_dir = Dir.mktmpdir + afsc_dir = File.join(@temp_dir, "gov_codes", "afsc") + # A future release date so `as_of: nil` (latest) resolves to this + # synthetic release rather than the gem's shipped one (release lists are + # unioned across the load path). + release_dir = File.join(afsc_dir, "releases", "dafecd", "2099-06-30") + FileUtils.mkdir_p(release_dir) + + File.write(File.join(afsc_dir, "releases.yml"), <<~YAML) + :dafecd: + - :effective_date: '2099-06-30' + :version_label: test + :source: synthetic.pdf + :name: Synthetic Directory + YAML + + File.write(File.join(release_dir, "enlisted.yml"), <<~YAML) + :"1A1X2": + :name: Mobility Force Aviator + :career_field: :"1A" + :skill_levels: + 7: + :code: 1A172 + :title: Craftsman + :shredouts: + :A: C-5 Flight Engineer + :Y: General + YAML + + $LOAD_PATH.unshift(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + after do + $LOAD_PATH.delete(@temp_dir) + FileUtils.rm_rf(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + it "resolves a concrete code against the versioned index" do + code = Enlisted.find("1A172") + _(code).wont_be_nil + _(code.specialty).must_equal :"1A1X2" + _(code.specialty_name).must_equal "Mobility Force Aviator" + _(code.name).must_equal "Mobility Force Aviator" + _(code.skill_level_number).must_equal 7 + _(code.skill_level_name).must_equal "Craftsman" + end + + it "carries the release effective_date on the resolved code" do + code = Enlisted.find("1A172") + _(code.effective_date).must_equal Date.new(2099, 6, 30) + end + + it "resolves a shredout name from the versioned index" do + code = Enlisted.find("1A172Y") + _(code.shredout).must_equal :Y + _(code.shredout_name).must_equal "General" + _(code.name).must_equal "General" + end + + it "returns nil for an as_of before the earliest release" do + _(Enlisted.find("1A172", as_of: "1900-01-01")).must_be_nil + end + + it "resolves the same code for an as_of within the release window" do + code = Enlisted.find("1A172", as_of: "2099-06-30") + _(code.specialty_name).must_equal "Mobility Force Aviator" + _(code.effective_date).must_equal Date.new(2099, 6, 30) + end + + it "returns nil for a specialty absent from the release" do + _(Enlisted.find("9Z9X9")).must_be_nil + end + + describe "search over the versioned index" do + it "emits the specialty and its shredout suffixes" do + codes = Enlisted.search("1A1X2").map { |c| "#{c.specific_afsc}#{c.shredout}" } + _(codes).must_include "1A1X2" + _(codes).must_include "1A1X2A" + _(codes).must_include "1A1X2Y" + end + + it "carries the release effective_date on searched codes" do + codes = Enlisted.search("1A1X2") + _(codes).wont_be_empty + codes.each { |c| _(c.effective_date).must_equal Date.new(2099, 6, 30) } + end + + it "is case-insensitive" do + _(Enlisted.search("1a1x2").map(&:specialty)).must_include :"1A1X2" + end + + it "returns an empty array for an as_of before the earliest release" do + _(Enlisted.search("1A1X2", as_of: "1900-01-01")).must_equal [] + end + end + end + + # A malformed consumer file on the load path must be skipped, not fatal: + # the shipped release still resolves (graceful degradation). + describe "Enlisted graceful degradation" do + before do + @temp_dir = Dir.mktmpdir + end + + after do + $LOAD_PATH.delete(@temp_dir) + FileUtils.rm_rf(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + def use_temp_load_path + $LOAD_PATH.unshift(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + it "skips a malformed consumer release index and uses the shipped one" do + release_dir = File.join(@temp_dir, "gov_codes", "afsc", "releases", "dafecd", "2025-10-31") + FileUtils.mkdir_p(release_dir) + File.write(File.join(release_dir, "enlisted.yml"), "invalid: yaml: content:") + use_temp_load_path + + code = Enlisted.find("1A172") + _(code).wont_be_nil + _(code.name).must_equal "Mobility Force Aviator" + _(code.effective_date).must_equal Date.new(2025, 10, 31) + end + + it "skips a malformed consumer manifest and uses the shipped one" do + afsc_dir = File.join(@temp_dir, "gov_codes", "afsc") + FileUtils.mkdir_p(afsc_dir) + File.write(File.join(afsc_dir, "releases.yml"), "invalid: yaml: content:") + use_temp_load_path + + code = Enlisted.find("1A172") + _(code).wont_be_nil + _(code.name).must_equal "Mobility Force Aviator" + _(code.effective_date).must_equal Date.new(2025, 10, 31) + end + end + + # M-1: equivalent as_of values (nil, the resolved Date, its string form) must + # share one memo slot rather than accumulating separate entries. + describe "Enlisted cache-key normalization" do + before { AFSC.reset_data(lookup: $LOAD_PATH) } + after { AFSC.reset_data(lookup: $LOAD_PATH) } + + it "shares a cache slot for equivalent as_of values" do + latest_date = Releases.effective_date_for(as_of: nil) + by_nil = Enlisted.find("1A172") + by_date = Enlisted.find("1A172", as_of: latest_date) + by_string = Enlisted.find("1A172", as_of: latest_date.to_s) + + _(by_date).must_be_same_as by_nil + _(by_string).must_be_same_as by_nil + _(by_nil.effective_date).must_equal latest_date + end + end + + # M-2: an unparseable as_of surfaces a clear ArgumentError, not a cryptic + # Date::Error, from the public find API. + describe "Enlisted invalid as_of" do + after { AFSC.reset_data(lookup: $LOAD_PATH) } + + it "raises a clear ArgumentError naming the bad value" do + error = _ { Enlisted.find("1A172", as_of: "not-a-date") }.must_raise ArgumentError + _(error.message).must_include "not-a-date" + end + end + end +end diff --git a/test/gov_codes/afsc/enlisted_test.rb b/test/gov_codes/afsc/enlisted_test.rb index 5ff1023..381528c 100644 --- a/test/gov_codes/afsc/enlisted_test.rb +++ b/test/gov_codes/afsc/enlisted_test.rb @@ -1,74 +1,85 @@ # frozen_string_literal: true require "test_helper" -require "tempfile" +require "tmpdir" require "fileutils" module GovCodes module AFSC + # Exercises DEC-004 extensibility: a consumer may drop a release index onto + # the load path to extend or override the shipped DAFECD release. describe Enlisted do before do - # Create a temporary directory for our test YAML file @temp_dir = Dir.mktmpdir - @gov_codes_dir = File.join(@temp_dir, "gov_codes", "afsc") - FileUtils.mkdir_p(@gov_codes_dir) - @temp_yaml_path = File.join(@gov_codes_dir, "enlisted.yml") + release_dir = File.join(@temp_dir, "gov_codes", "afsc", "releases", "dafecd", "2025-10-31") + FileUtils.mkdir_p(release_dir) - # Create a sample YAML file with test data - File.write(@temp_yaml_path, <<~YAML) - 9Z: - name: Test Operations - subcategories: - 0X1: - name: Test Operations Group - subcategories: - A: - name: Test Operations Apprentice + # No manifest here: the shipped manifest already lists 2025-10-31, so the + # loader resolves that release and merges this index over the gem's. + File.write(File.join(release_dir, "enlisted.yml"), <<~YAML) + :"9Z9X9": + :name: Custom Specialty + :career_field: :"9Z" + :skill_levels: + 7: + :code: 9Z979 + :title: Craftsman + :shredouts: + :A: Custom Shredout + :"1A1X2": + :name: Overridden Aviator + :career_field: :"1A" + :skill_levels: {} + :shredouts: {} YAML + + $LOAD_PATH.unshift(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) end after do - # Clean up the temporary directory + $LOAD_PATH.delete(@temp_dir) FileUtils.rm_rf(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) end - describe "#data" do - it "merges YAML files from lookup path" do - data = Enlisted.data(lookup: [@temp_dir]) + describe "#find" do + it "merges a consumer-supplied release index across the load path" do + code = Enlisted.find("9Z9X9") + _(code).wont_be_nil + _(code.specific_afsc.to_s).must_equal "9Z9X9" + _(code.name).must_equal "Custom Specialty" + end - # Verify that our test data is present - _(data[:"9Z"]).wont_be :nil? - _(data.dig(:"9Z", :name)).must_equal "Test Operations" - _(data.dig(:"9Z", :subcategories, :"0X1")).wont_be :nil? - _(data.dig(:"9Z", :subcategories, :"0X1", :name)).must_equal "Test Operations Group" - _(data.dig(:"9Z", :subcategories, :"0X1", :subcategories, :A)).wont_be :nil? - _(data.dig(:"9Z", :subcategories, :"0X1", :subcategories, :A, :name)).must_equal "Test Operations Apprentice" + it "reads the skill-level title from the consumer index" do + code = Enlisted.find("9Z979") + _(code.skill_level_number).must_equal 7 + _(code.skill_level_name).must_equal "Craftsman" end - it "works with custom lookup path" do - data = Enlisted.data(lookup: [@temp_dir]) - _(data.dig(:"9Z", :name)).must_equal "Test Operations" - _(data.dig(:"9Z", :subcategories, :"0X1")).wont_be :nil? - _(data.dig(:"9Z", :subcategories, :"0X1", :name)).must_equal "Test Operations Group" - _(data.dig(:"9Z", :subcategories, :"0X1", :subcategories, :A)).wont_be :nil? - _(data.dig(:"9Z", :subcategories, :"0X1", :subcategories, :A, :name)).must_equal "Test Operations Apprentice" + it "resolves a consumer-supplied shredout" do + code = Enlisted.find("9Z9X9A") + _(code.shredout).must_equal :A + _(code.shredout_name).must_equal "Custom Shredout" + _(code.name).must_equal "Custom Shredout" end - end - describe "#find" do - it "uses merged data" do - Enlisted.reset_data(lookup: [@temp_dir]) - code = Enlisted.find("9Z0X1") - _(code).wont_be :nil? - _(code.specific_afsc.to_s).must_equal "9Z0X1" - _(code.name).must_equal "Test Operations Group" + it "lets a consumer override a shipped specialty for the same release" do + code = Enlisted.find("1A1X2") + _(code.name).must_equal "Overridden Aviator" + end + + it "still resolves a shipped specialty absent from the consumer index" do + # 6C0X1 is only in the gem's shipped index; proving merge, not replace. + code = Enlisted.find("6C0X1") + _(code).wont_be_nil + _(code.name).must_equal "Contracting" end - it "returns correct object" do - AFSC.reset_data(lookup: [@temp_dir]) - code = Enlisted.find("9Z0X1") - _(code.specific_afsc.to_s).must_equal "9Z0X1" - _(code.name).must_equal "Test Operations Group" + it "routes through AFSC.find" do + code = AFSC.find("9Z9X9") + _(code).must_be_instance_of Enlisted::Code + _(code.name).must_equal "Custom Specialty" end end end diff --git a/test/gov_codes/afsc/releases_test.rb b/test/gov_codes/afsc/releases_test.rb new file mode 100644 index 0000000..cd5774d --- /dev/null +++ b/test/gov_codes/afsc/releases_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" +require "fileutils" +require "date" +require "gov_codes/afsc/releases" + +module GovCodes + module AFSC + describe Releases do + before do + @temp_dir = Dir.mktmpdir + release_dir = File.join(@temp_dir, "gov_codes", "afsc", "releases", "dafecd") + afsc_dir = File.join(@temp_dir, "gov_codes", "afsc") + FileUtils.mkdir_p(afsc_dir) + FileUtils.mkdir_p(File.join(release_dir, "2025-04-30")) + FileUtils.mkdir_p(File.join(release_dir, "2025-10-31")) + + File.write(File.join(afsc_dir, "releases.yml"), <<~YAML) + :dafecd: + - :effective_date: '2025-04-30' + :version_label: v3.4 + :source: synthetic-apr.pdf + :name: Synthetic April Directory + - :effective_date: '2025-10-31' + :version_label: v3.5 + :source: synthetic-oct.pdf + :name: Synthetic October Directory + YAML + + File.write(File.join(release_dir, "2025-04-30", "enlisted.yml"), <<~YAML) + :"9Y9X9": + :name: Apr Synthetic + :career_field: :"9Y" + :skill_levels: {} + :shredouts: {} + YAML + + File.write(File.join(release_dir, "2025-10-31", "enlisted.yml"), <<~YAML) + :"9Z9X9": + :name: Oct Synthetic + :career_field: :"9Z" + :skill_levels: {} + :shredouts: {} + YAML + end + + after do + FileUtils.rm_rf(@temp_dir) + Releases.reset! + end + + describe ".manifest" do + it "merges the release manifest from the lookup path" do + manifest = Releases.manifest(lookup: [@temp_dir]) + dates = manifest[:dafecd].map { |r| r[:effective_date] } + _(dates).must_include "2025-04-30" + _(dates).must_include "2025-10-31" + end + end + + describe ".enlisted_index" do + it "resolves the latest release when as_of is nil" do + index = Releases.enlisted_index(as_of: nil, lookup: [@temp_dir]) + _(index.key?(:"9Z9X9")).must_equal true + _(index.key?(:"9Y9X9")).must_equal false + end + + it "resolves the release effective on the given date" do + index = Releases.enlisted_index(as_of: "2025-10-31", lookup: [@temp_dir]) + _(index.key?(:"9Z9X9")).must_equal true + _(index.key?(:"9Y9X9")).must_equal false + end + + it "resolves the prior release for a date between releases" do + index = Releases.enlisted_index(as_of: "2025-05-01", lookup: [@temp_dir]) + _(index.key?(:"9Y9X9")).must_equal true + _(index.key?(:"9Z9X9")).must_equal false + end + + it "returns an empty hash for a date before the earliest release" do + index = Releases.enlisted_index(as_of: "2025-01-01", lookup: [@temp_dir]) + _(index).must_equal({}) + end + + it "accepts a Date object for as_of" do + index = Releases.enlisted_index(as_of: Date.new(2025, 5, 1), lookup: [@temp_dir]) + _(index.key?(:"9Y9X9")).must_equal true + end + end + + describe ".effective_date_for" do + it "returns the latest release date when as_of is nil" do + _(Releases.effective_date_for(as_of: nil, lookup: [@temp_dir])) + .must_equal Date.new(2025, 10, 31) + end + + it "returns the resolved release date for a date between releases" do + _(Releases.effective_date_for(as_of: "2025-05-01", lookup: [@temp_dir])) + .must_equal Date.new(2025, 4, 30) + end + + it "returns nil for a date before the earliest release" do + _(Releases.effective_date_for(as_of: "2025-01-01", lookup: [@temp_dir])) + .must_be_nil + end + end + + describe "invalid as_of" do + it "raises a clear ArgumentError naming the bad value" do + error = _ { Releases.enlisted_index(as_of: "not-a-date", lookup: [@temp_dir]) } + .must_raise ArgumentError + _(error.message).must_include "not-a-date" + _(error.message).must_include "as_of" + end + + it "raises for an invalid as_of in effective_date_for" do + _ { Releases.effective_date_for(as_of: "nonsense", lookup: [@temp_dir]) } + .must_raise ArgumentError + end + end + end + + # DEC-004: a consumer manifest must MERGE its releases into the shipped list + # (union by effective_date), not replace it. Adding a release must not hide + # the gem's shipped 2025-10-31 release. + describe "Releases manifest merging" do + before do + @temp_dir = Dir.mktmpdir + afsc_dir = File.join(@temp_dir, "gov_codes", "afsc") + future_dir = File.join(afsc_dir, "releases", "dafecd", "2099-12-31") + FileUtils.mkdir_p(future_dir) + + # Consumer lists ONLY their own (future) release, omitting the shipped one. + File.write(File.join(afsc_dir, "releases.yml"), <<~YAML) + :dafecd: + - :effective_date: '2099-12-31' + :version_label: future + :source: synthetic-future.pdf + :name: Synthetic Future Directory + YAML + + File.write(File.join(future_dir, "enlisted.yml"), <<~YAML) + :"9Q9X9": + :name: Future Synthetic + :career_field: :"9Q" + :skill_levels: {} + :shredouts: {} + YAML + end + + after do + FileUtils.rm_rf(@temp_dir) + Releases.reset! + end + + it "unions the consumer release list with the shipped list" do + dates = Releases.manifest(lookup: [@temp_dir])[:dafecd].map { |r| r[:effective_date] } + _(dates).must_include "2025-10-31" # shipped, not hidden + _(dates).must_include "2099-12-31" # consumer-added + end + + it "keeps the shipped release resolvable after a consumer adds a release" do + index = Releases.enlisted_index(as_of: "2025-11-01", lookup: [@temp_dir]) + _(index.key?(:"1A1X2")).must_equal true + _(index.dig(:"1A1X2", :name)).must_equal "Mobility Force Aviator" + end + + it "resolves the consumer-added release as the latest" do + index = Releases.enlisted_index(as_of: nil, lookup: [@temp_dir]) + _(index.key?(:"9Q9X9")).must_equal true + _(Releases.effective_date_for(as_of: nil, lookup: [@temp_dir])) + .must_equal Date.new(2099, 12, 31) + end + end + end +end diff --git a/test/gov_codes/afsc_test.rb b/test/gov_codes/afsc_test.rb index ec5d479..c5d46e7 100644 --- a/test/gov_codes/afsc_test.rb +++ b/test/gov_codes/afsc_test.rb @@ -19,7 +19,7 @@ def test_find_returns_enlisted_code_for_valid_enlisted_afsc assert_equal :X, code.skill_level assert_equal :"1A1X2", code.specific_afsc assert_nil code.shredout - assert_equal "Mobility force aviator", code.name + assert_equal "Mobility Force Aviator", code.name end def test_find_returns_officer_code_for_valid_officer_afsc @@ -128,7 +128,7 @@ def test_find_financial_management_afsc assert_instance_of GovCodes::AFSC::Enlisted::Code, code assert_equal :"6F", code.career_field - assert_equal "Financial management and comptroller", code.name + assert_equal "Financial Management and Comptroller", code.name end def test_find_special_investigations_afsc @@ -136,7 +136,7 @@ def test_find_special_investigations_afsc assert_instance_of GovCodes::AFSC::Enlisted::Code, code assert_equal :"7S", code.career_field - assert_equal "Special investigations", code.name + assert_equal "Special Investigations", code.name end def test_find_chief_enlisted_manager_codes diff --git a/test/gov_codes/afsc_versioned_test.rb b/test/gov_codes/afsc_versioned_test.rb new file mode 100644 index 0000000..8edd847 --- /dev/null +++ b/test/gov_codes/afsc_versioned_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" +require "fileutils" +require "date" + +module GovCodes + describe "AFSC versioned lookup" do + before do + @temp_dir = Dir.mktmpdir + afsc_dir = File.join(@temp_dir, "gov_codes", "afsc") + # Future release date: `as_of: nil` (latest) resolves to this synthetic + # release, which the loader unions with the gem's shipped release. + release_dir = File.join(afsc_dir, "releases", "dafecd", "2099-06-30") + FileUtils.mkdir_p(release_dir) + + File.write(File.join(afsc_dir, "releases.yml"), <<~YAML) + :dafecd: + - :effective_date: '2099-06-30' + :version_label: test + :source: synthetic.pdf + :name: Synthetic Directory + YAML + + File.write(File.join(release_dir, "enlisted.yml"), <<~YAML) + :"1A1X2": + :name: Mobility Force Aviator + :career_field: :"1A" + :skill_levels: + 7: + :code: 1A172 + :title: Craftsman + :shredouts: + :Y: General + YAML + + $LOAD_PATH.unshift(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + after do + $LOAD_PATH.delete(@temp_dir) + FileUtils.rm_rf(@temp_dir) + AFSC.reset_data(lookup: $LOAD_PATH) + end + + it "threads as_of into the enlisted lookup" do + code = AFSC.find("1A172") + _(code).must_be_instance_of AFSC::Enlisted::Code + _(code.name).must_equal "Mobility Force Aviator" + _(code.effective_date).must_equal Date.new(2099, 6, 30) + end + + it "returns nil when as_of precedes the earliest release" do + _(AFSC.find("1A172", as_of: "1900-01-01")).must_be_nil + end + + it "threads as_of into search" do + codes = AFSC.search("1A1X2", as_of: "2099-06-30").map(&:specialty) + _(codes).must_include :"1A1X2" + end + + it "returns no enlisted search results before the earliest release" do + results = AFSC.search("1A1X2", as_of: "1900-01-01") + _(results.select { |r| r.is_a?(AFSC::Enlisted::Code) }).must_equal [] + end + end +end