Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions cmd/bammm/convert_priority_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions cmd/bammm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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...]",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions internal/splat/priority.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions internal/splat/priority_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading