diff --git a/SPEC.md b/SPEC.md index d0b2e7f..bd45706 100644 --- a/SPEC.md +++ b/SPEC.md @@ -94,10 +94,13 @@ spec: queue: string # target queue / partition name # Slurm: --partition; PBS: -q; LSF: -q; Flux: --queue # Armada/Volcano/Kueue/YuniKorn: queue or localQueue name - priority: integer # canonical 0–1000 scale, HIGHER = HIGHER PRIORITY (500 = normal) + priority: integer # canonical 0–1000 scale by default, HIGHER = HIGHER PRIORITY (500 = normal) # Each scheduler's native priority is normalized into this band on # parse and denormalized back to its native range on emit, so cross- # scheduler conversions stay in-range and keep the right direction. + # The band is configurable: `bammm convert --priority-range MIN:MAX` + # (e.g. 0:100000) widens it to reduce round-trip rounding loss for + # schedulers with large native ranges. # Native ranges: Slurm 0–1000, PBS -1024..1023, HTCondor -1000..1000, # Armada 0–1000. nice/fair-share style values (lower = higher) invert. # Slurm: --priority; PBS: -p; HTCondor: priority; LSF: -sp; Flux: --urgency diff --git a/cmd/bammm/convert_priority_test.go b/cmd/bammm/convert_priority_test.go new file mode 100644 index 0000000..3779823 --- /dev/null +++ b/cmd/bammm/convert_priority_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "io" + "strings" + "testing" + + "github.com/InsightSoftmax/BAMMM/internal/splat" +) + +func runConvert(t *testing.T, in string, args ...string) error { + t.Helper() + cmd := newConvertCmd() + cmd.SetArgs(args) + cmd.SetIn(strings.NewReader(in)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return cmd.Execute() +} + +func TestConvert_PriorityRangeFlag(t *testing.T) { + orig := splat.CanonicalScale + defer func() { splat.CanonicalScale = orig }() + + // A malformed range is rejected before any conversion runs. + if err := runConvert(t, validSlurm, "--from", "slurm", "--to", "pbs", "--priority-range", "10:2"); err == nil { + t.Fatal("expected an error for MAX <= MIN") + } + if err := runConvert(t, validSlurm, "--from", "slurm", "--to", "pbs", "--priority-range", "oops"); err == nil { + t.Fatal("expected an error for a non-numeric range") + } + + // A valid range is applied to the process-global canonical scale. + if err := runConvert(t, validSlurm, "--from", "slurm", "--to", "pbs", "--priority-range", "0:5000"); err != nil { + t.Fatalf("convert with valid range: %v", err) + } + if splat.CanonicalScale.Min != 0 || splat.CanonicalScale.Max != 5000 { + t.Errorf("canonical scale not applied: got %+v want {0 5000}", splat.CanonicalScale) + } + + // Omitting the flag restores the 0–1000 default. + if err := runConvert(t, validSlurm, "--from", "slurm", "--to", "pbs"); err != nil { + t.Fatalf("convert with default range: %v", err) + } + if splat.CanonicalScale.Min != 0 || splat.CanonicalScale.Max != 1000 { + t.Errorf("default scale: got %+v want {0 1000}", splat.CanonicalScale) + } +} diff --git a/cmd/bammm/main.go b/cmd/bammm/main.go index 6bd716e..bafbb89 100644 --- a/cmd/bammm/main.go +++ b/cmd/bammm/main.go @@ -14,6 +14,7 @@ import ( _ "github.com/InsightSoftmax/BAMMM/internal/emitter/all" "github.com/InsightSoftmax/BAMMM/internal/parser" _ "github.com/InsightSoftmax/BAMMM/internal/parser/all" + "github.com/InsightSoftmax/BAMMM/internal/splat" ) // version is stamped at build time by GoReleaser: @@ -44,6 +45,7 @@ func newRootCmd() *cobra.Command { func newConvertCmd() *cobra.Command { var from, to, inputFile, inputDir, outputDir, pattern string var recursive, report bool + var priorityRange string cmd := &cobra.Command{ Use: "convert [file...]", @@ -65,6 +67,12 @@ Use --from splat / --to splat to validate or round-trip without converting.`, bammm convert --from slurm --to kueue --input-dir corpus/slurm --output-dir out/`, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { + if scale, err := splat.ParseRange(priorityRange); err != nil { + return err + } else { + splat.CanonicalScale = scale + } + items, batch, err := gatherInputs(args, inputDir, pattern, recursive) if err != nil { return err @@ -104,6 +112,7 @@ Use --from splat / --to splat to validate or round-trip without converting.`, cmd.Flags().StringVar(&pattern, "pattern", "", "filename glob to filter --input-dir (e.g. '*.sbatch')") cmd.Flags().BoolVar(&recursive, "recursive", true, "recurse into subdirectories of --input-dir") cmd.Flags().BoolVar(&report, "report", false, "print a SPLAT field-coverage report over the inputs") + cmd.Flags().StringVar(&priorityRange, "priority-range", "0:1000", "canonical priority band MIN:MAX; widen to reduce round-trip rounding loss") _ = cmd.MarkFlagRequired("from") _ = cmd.MarkFlagRequired("to") return cmd diff --git a/internal/splat/priority.go b/internal/splat/priority.go index 54e4e70..b44cdb9 100644 --- a/internal/splat/priority.go +++ b/internal/splat/priority.go @@ -1,6 +1,11 @@ package splat -import "math" +import ( + "fmt" + "math" + "strconv" + "strings" +) // PriorityScale describes a scheduler's native priority convention so BAMMM can // map it to and from SPLAT's canonical priority band: an integer 0–1000 where @@ -25,30 +30,63 @@ var ( ArmadaPriority = PriorityScale{Min: 0, Max: 1000, Invert: false} ) -// Normalize maps a native priority value onto the canonical 0–1000 band. +// CanonicalScale is the interchange band that every native priority is mapped +// onto and back off of, with higher always meaning higher priority. It defaults +// to 0–1000; widening it (e.g. via `bammm convert --priority-range`) reduces the +// quantization loss when round-tripping schedulers with large native ranges. +// It is process-global: set it once before converting. +var CanonicalScale = PriorityScale{Min: 0, Max: 1000} + +// Normalize maps a native priority value onto the canonical band (CanonicalScale). func (s PriorityScale) Normalize(native int) int { if s.Max == s.Min { - return 0 + return CanonicalScale.Min } n := clampInt(native, s.Min, s.Max) frac := float64(n-s.Min) / float64(s.Max-s.Min) // 0..1, higher native → higher frac if s.Invert { frac = 1 - frac } - return int(math.Round(frac * 1000)) + return CanonicalScale.Min + int(math.Round(frac*float64(CanonicalScale.Max-CanonicalScale.Min))) } -// Denormalize maps a canonical 0–1000 priority back to the scheduler's native -// range and direction. The result is always a valid native value. +// Denormalize maps a canonical priority (on CanonicalScale) back to the +// scheduler's native range and direction. The result is always a valid native +// value. func (s PriorityScale) Denormalize(canonical int) int { - c := clampInt(canonical, 0, 1000) - frac := float64(c) / 1000 + if CanonicalScale.Max == CanonicalScale.Min { + return s.Min + } + c := clampInt(canonical, CanonicalScale.Min, CanonicalScale.Max) + frac := float64(c-CanonicalScale.Min) / float64(CanonicalScale.Max-CanonicalScale.Min) if s.Invert { frac = 1 - frac } return s.Min + int(math.Round(frac*float64(s.Max-s.Min))) } +// ParseRange parses a "MIN:MAX" priority band (e.g. "0:10000") into a +// non-inverted canonical PriorityScale. It errors on malformed input or when +// MAX does not exceed MIN. +func ParseRange(s string) (PriorityScale, error) { + lo, hi, ok := strings.Cut(s, ":") + if !ok { + return PriorityScale{}, fmt.Errorf("priority range %q: expected MIN:MAX", s) + } + min, err := strconv.Atoi(strings.TrimSpace(lo)) + if err != nil { + return PriorityScale{}, fmt.Errorf("priority range %q: bad MIN: %w", s, err) + } + max, err := strconv.Atoi(strings.TrimSpace(hi)) + if err != nil { + return PriorityScale{}, fmt.Errorf("priority range %q: bad MAX: %w", s, err) + } + if max <= min { + return PriorityScale{}, fmt.Errorf("priority range %q: MAX must exceed MIN", s) + } + return PriorityScale{Min: min, Max: max}, nil +} + func clampInt(v, lo, hi int) int { if v < lo { return lo diff --git a/internal/splat/priority_test.go b/internal/splat/priority_test.go index b1aceb2..7f8d86b 100644 --- a/internal/splat/priority_test.go +++ b/internal/splat/priority_test.go @@ -48,6 +48,50 @@ func TestPriorityScale_Invert(t *testing.T) { } } +func TestParseRange(t *testing.T) { + s, err := ParseRange("0:10000") + if err != nil || s.Min != 0 || s.Max != 10000 { + t.Fatalf("ParseRange(0:10000): got %+v, %v", s, err) + } + if s, err := ParseRange(" -5 : 5 "); err != nil || s.Min != -5 || s.Max != 5 { + t.Errorf("ParseRange with spaces: got %+v, %v", s, err) + } + for _, bad := range []string{"", "0", "5:5", "10:2", "a:b", "0:x"} { + if _, err := ParseRange(bad); err == nil { + t.Errorf("ParseRange(%q): expected error", bad) + } + } +} + +func TestPriority_WideCanonicalImprovesRoundTrip(t *testing.T) { + orig := CanonicalScale + defer func() { CanonicalScale = orig }() + + // PBS has 2048 distinct native values; the default 0–1000 band cannot + // represent them all, so some native→canonical→native round-trips lose + // precision. Count how many. + lossy := func() int { + n := 0 + for v := PBSPriority.Min; v <= PBSPriority.Max; v++ { + if PBSPriority.Denormalize(PBSPriority.Normalize(v)) != v { + n++ + } + } + return n + } + + CanonicalScale = PriorityScale{Min: 0, Max: 1000} + if lossy() == 0 { + t.Fatal("expected the default 0–1000 band to lose precision across PBS's range") + } + + // A band at least as wide as the native range makes the round-trip lossless. + CanonicalScale = PriorityScale{Min: 0, Max: 100000} + if n := lossy(); n != 0 { + t.Errorf("wide band should be lossless, got %d lossy values", n) + } +} + func TestPriorityScale_DirectionAgreement(t *testing.T) { // Two schedulers with opposite native directions must agree on canonical // ordering: the higher-priority job stays higher-priority after conversion.