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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ Wherever the translation is lossy, BAMMM tells you exactly what got dropped, wha
| **HTCondor** | HPC | ✅ | ✅ |
| **Flux** | HPC | 🚧 | 🚧 |
| **YuniKorn** | K8s | ✅ | ✅ |
| **Run.ai** | K8s (Partner) | ✅ | ✅ |
| **LSF** | HPC (Community) | 🚧 | 🚧 |
| **Run.ai** | K8s | ✅ | ✅ |
| **LSF** | HPC | 🚧 | 🚧 |

Plus `splat` as both `--from` and `--to` for validation / round-tripping. Run `bammm formats` for the live list.

Expand Down
8 changes: 6 additions & 2 deletions cmd/bammm/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/InsightSoftmax/BAMMM/internal/convert"
"github.com/InsightSoftmax/BAMMM/internal/rules"
)

// inputItem is one file to convert. rel is the path relative to the output
Expand Down Expand Up @@ -93,7 +94,7 @@ type batchResult struct {
// swapping each file's extension for the target format's. It continues past
// per-file failures, writing a summary to w, and only returns a non-nil error
// for problems that abort the whole run (e.g. an unwritable output dir).
func runBatch(items []inputItem, from, to, outDir string, w io.Writer) (batchResult, error) {
func runBatch(items []inputItem, from, to, outDir string, w io.Writer, rs *rules.Ruleset) (batchResult, error) {
var res batchResult
ext := convert.Extension(to)

Expand All @@ -106,11 +107,14 @@ func runBatch(items []inputItem, from, to, outDir string, w io.Writer) (batchRes
res.failures = append(res.failures, batchFailure{it.rel, err.Error()})
continue
}
out, err := convert.Convert(data, from, to)
out, warnings, err := convert.ConvertWithRules(data, from, to, rs)
if err != nil {
res.failures = append(res.failures, batchFailure{it.rel, err.Error()})
continue
}
for _, warn := range warnings {
fmt.Fprintf(w, " %s: rule: %s\n", it.rel, warn)
}
outPath := filepath.Join(outDir, swapExt(it.rel, ext))
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return res, fmt.Errorf("creating %s: %w", filepath.Dir(outPath), err)
Expand Down
2 changes: 1 addition & 1 deletion cmd/bammm/batch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func TestRunBatch_ContinuesAndMirrors(t *testing.T) {
}

var buf strings.Builder
res, err := runBatch(items, "slurm", "splat", out, &buf)
res, err := runBatch(items, "slurm", "splat", out, &buf, nil)
if err != nil {
t.Fatalf("runBatch: %v", err)
}
Expand Down
55 changes: 55 additions & 0 deletions cmd/bammm/convert_rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

func TestConvert_RulesFlag(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "rules.yaml")
if err := os.WriteFile(rulesPath, []byte(`
apiVersion: bammm.io/rules/v1alpha1
rules:
- when:
equals:
spec.schedule.qos: debug
set:
spec.schedule.queue: debug-queue
warn: routed debug qos
`), 0o644); err != nil {
t.Fatal(err)
}

in := "#!/bin/bash\n#SBATCH --job-name=t\n#SBATCH --qos=debug\n#SBATCH --ntasks=1\necho hi\n"
var out, errBuf bytes.Buffer
cmd := newConvertCmd()
cmd.SetArgs([]string{"--from", "slurm", "--to", "pbs", "--rules", rulesPath})
cmd.SetIn(strings.NewReader(in))
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
if err := cmd.Execute(); err != nil {
t.Fatalf("convert: %v", err)
}

if !strings.Contains(out.String(), "#PBS -q debug-queue") {
t.Errorf("rule did not route the queue:\n%s", out.String())
}
if !strings.Contains(errBuf.String(), "routed debug qos") {
t.Errorf("expected rule warning on stderr, got: %q", errBuf.String())
}
}

func TestConvert_RulesFileMissing(t *testing.T) {
cmd := newConvertCmd()
cmd.SetArgs([]string{"--from", "slurm", "--to", "pbs", "--rules", "/no/such/rules.yaml"})
cmd.SetIn(strings.NewReader("#SBATCH --ntasks=1\n"))
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected an error for a missing --rules file")
}
}
32 changes: 27 additions & 5 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/rules"
"github.com/InsightSoftmax/BAMMM/internal/splat"
)

Expand Down Expand Up @@ -45,7 +46,7 @@ func newRootCmd() *cobra.Command {
func newConvertCmd() *cobra.Command {
var from, to, inputFile, inputDir, outputDir, pattern string
var recursive, report bool
var priorityRange string
var priorityRange, rulesFile string

cmd := &cobra.Command{
Use: "convert [file...]",
Expand Down Expand Up @@ -73,18 +74,23 @@ Use --from splat / --to splat to validate or round-trip without converting.`,
splat.CanonicalScale = scale
}

ruleSet, err := loadRules(rulesFile)
if err != nil {
return err
}

items, batch, err := gatherInputs(args, inputDir, pattern, recursive)
if err != nil {
return err
}

var runErr error
if !batch {
runErr = convertSingle(cmd, items, inputFile, from, to, outputDir)
runErr = convertSingle(cmd, items, inputFile, from, to, outputDir, ruleSet)
} else if outputDir == "" {
return fmt.Errorf("--output-dir is required when converting multiple files or --input-dir")
} else {
res, err := runBatch(items, from, to, outputDir, cmd.ErrOrStderr())
res, err := runBatch(items, from, to, outputDir, cmd.ErrOrStderr(), ruleSet)
if err != nil {
return err
}
Expand Down Expand Up @@ -113,14 +119,27 @@ Use --from splat / --to splat to validate or round-trip without converting.`,
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.Flags().StringVar(&rulesFile, "rules", "", "user rules file to rewrite the SPLAT IR mid-conversion (see docs/translation-rules.md)")
_ = cmd.MarkFlagRequired("from")
_ = cmd.MarkFlagRequired("to")
return cmd
}

// loadRules reads and parses a --rules file, returning nil when none is given.
func loadRules(path string) (*rules.Ruleset, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading --rules: %w", err)
}
return rules.Load(data)
}

// convertSingle handles the one-input case: read from a file/stdin and write to
// stdout, or to a single named file when --output-dir is set.
func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, outputDir string) error {
func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, outputDir string, rs *rules.Ruleset) error {
var args []string
if len(items) == 1 {
args = []string{items[0].path}
Expand All @@ -129,10 +148,13 @@ func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, o
if err != nil {
return fmt.Errorf("reading input: %w", err)
}
out, err := convert.Convert(data, from, to)
out, warnings, err := convert.ConvertWithRules(data, from, to, rs)
if err != nil {
return err
}
for _, warn := range warnings {
fmt.Fprintf(cmd.ErrOrStderr(), "rule: %s\n", warn)
}
if outputDir == "" {
_, err = fmt.Fprint(cmd.OutOrStdout(), string(out))
return err
Expand Down
117 changes: 45 additions & 72 deletions docs/translation-rules.md
Original file line number Diff line number Diff line change
@@ -1,91 +1,64 @@
# User-defined translation rules (design — planned)
# User-defined translation rules

> **Status: proposed, not yet implemented.** This document is the design for a
> feature that lets users adjust translations *without patching BAMMM's source*.
> It exists so the feature is specified before it is built; nothing here works
> today.

## Problem

BAMMM maps native fields to SPLAT and back. When it meets something it doesn't
anticipate — a site-specific resource, a custom directive, a field a given
scheduler version added — the current options are:

1. it lands in `extensions.<scheduler>` (preserved for round-trip, but **not**
translated to a *different* scheduler), or
2. it's dropped.

Neither lets a user say "when converting X → Y, turn *this* into *that*" without
editing Go and rebuilding.

## Approach: a declarative rules file

A user supplies a rules file and points BAMMM at it:
Adjust a conversion **without patching BAMMM**: supply a rules file that rewrites
the SPLAT intermediate representation between parse and emit.

```sh
bammm convert --from slurm --to pbs --rules my-rules.yaml job.sh
```

Rules operate on the **SPLAT IR** (after parse, before emit), so one rule set
works regardless of the source/target pair. Each rule is a `match` → `action`.
Rules operate on the SPLAT IR (after the source is parsed, before the target is
emitted), so one rule set works regardless of the source/target pair. See a
worked example in [`examples/rules/`](../examples/rules/).

## Why

### Sketch
When BAMMM meets something it doesn't anticipate — a site-specific resource, a
custom directive, a field a scheduler version added — it either preserves it
under `extensions.<scheduler>` (round-trips back, but doesn't translate to a
*different* scheduler) or drops it. Rules let you say "when converting X → Y,
turn *this* into *that*" yourself.

## Format

```yaml
apiVersion: bammm.io/rules/v1alpha1
rules:
# Promote a preserved Slurm extension into a first-class field for the target.
- when:
from: slurm
has: extensions.slurm.switches # dotted path into the SPLAT IR
set:
placement.constraint: "network=switch" # target-agnostic SPLAT field

# Rename / remap a value.
- when:
has: schedule.qos
equals: { schedule.qos: debug }
set:
schedule.queue: debug-queue

# Drop with an explicit warning instead of silent loss.
- when:
to: pbs
has: extensions.htcondor.requirements
drop:
warn: "HTCondor requirements expression cannot be expressed in PBS"
from: slurm # optional: source format must equal this
to: pbs # optional: target format must equal this
has: spec.schedule.qos # optional: path(s) must exist (string or list)
equals: # optional: path == value
spec.schedule.qos: debug
# Actions, applied in this order when `when` matches:
default: # set only if the path is absent
spec.schedule.queue: normal
set: # set (overwrite)
spec.schedule.queue: debug-queue
rename: # move a value from one path to another
spec.schedule.account: spec.schedule.project
remove: # delete path(s) (string or list)
- spec.extensions.slurm.switches
warn: routed debug QoS to debug-queue # optional: printed to stderr when the rule fires
```

Action verbs (initial set): `set`, `rename`, `drop` (with `warn`), `default`
(set only if absent).

### Match expressions

Start with simple structural predicates (`has`, `equals`, `from`, `to`). If that
proves too limited, add an embedded **CEL** (`cel-go`) expression for conditions
and computed values — sandboxed, no code execution, e.g.:

```yaml
- when:
expr: 'has(spec.resources.gpu) && spec.resources.gpu.count > 4'
set:
schedule.queue: gpu-large
```
### Paths

Starlark or external pre/post hooks are a possible later escalation for
transforms that a declarative form can't express, but the declarative rules file
is the primary interface.
Paths are **full SPLAT document paths**, exactly as they appear in
`bammm convert --to splat` output — e.g. `spec.schedule.queue`,
`metadata.name`, `spec.extensions.htcondor.requirements`. (Array indices are not
supported yet.)

## Open questions
### Matching

- Precedence when multiple rules match (first-wins vs. all-apply).
- Whether rules may write into `extensions.*` (target-specific escape hatch).
- How rule-driven changes surface in `--report` and the lossiness accounting.
- Packaging: site-wide default rules vs. per-invocation `--rules`.
All conditions in a `when` must hold for the rule to fire. An empty `when: {}`
always matches. Multiple rules are evaluated in order; each matching rule's
actions apply cumulatively.

## When built
## Roadmap

This feature must ship with: a `SPEC` section (or its own `SPEC-rules.md`), CLI
help for `--rules`, worked examples under `examples/rules/`, and a note in
[gotchas.md](gotchas.md) about rule precedence. Update this document from
"proposed" to the real reference at that point.
- **CEL expressions** (`cel-go`) for `when` conditions and computed values, when
the structural predicates above prove too limited.
- **Array-index paths** for list elements.
- Starlark or external pre/post hooks for transforms a declarative form can't
express — an escalation, not a replacement for the rules file.
22 changes: 22 additions & 0 deletions examples/rules/route-debug-qos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Example BAMMM translation rules. Apply with:
# bammm convert --from slurm --to pbs --rules examples/rules/route-debug-qos.yaml job.sh
#
# Paths are full SPLAT document paths (what you see in `--to splat` output).
apiVersion: bammm.io/rules/v1alpha1
rules:
# Jobs submitted at the "debug" QoS should land in the debug queue on the target.
- when:
equals:
spec.schedule.qos: debug
set:
spec.schedule.queue: debug-queue
warn: routed debug QoS to the debug-queue

# HTCondor requirements expressions can't be expressed in PBS — drop them loudly
# rather than silently carrying a dead extension across.
- when:
to: pbs
has: spec.extensions.htcondor.requirements
remove:
- spec.extensions.htcondor.requirements
warn: dropped HTCondor requirements (no PBS equivalent)
21 changes: 19 additions & 2 deletions internal/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/InsightSoftmax/BAMMM/internal/emitter"
"github.com/InsightSoftmax/BAMMM/internal/parser"
"github.com/InsightSoftmax/BAMMM/internal/rules"
"github.com/InsightSoftmax/BAMMM/internal/splat"
)

Expand All @@ -27,11 +28,27 @@ func Extension(format string) string {
// Convert translates job spec bytes from one scheduler format to another.
// Use "splat" as from/to for SPLAT pass-through (useful for validation).
func Convert(input []byte, from, to string) ([]byte, error) {
out, _, err := ConvertWithRules(input, from, to, nil)
return out, err
}

// ConvertWithRules is Convert plus an optional user rule set that rewrites the
// SPLAT IR between parse and emit. It also returns any warnings the matched
// rules emitted. A nil ruleset is a plain conversion.
func ConvertWithRules(input []byte, from, to string, rs *rules.Ruleset) ([]byte, []string, error) {
job, err := parse(input, from)
if err != nil {
return nil, err
return nil, nil, err
}
warnings, err := rs.Apply(job, from, to)
if err != nil {
return nil, nil, err
}
out, err := emit(job, to)
if err != nil {
return nil, warnings, err
}
return emit(job, to)
return out, warnings, nil
}

// Parse converts source bytes into a SPLAT job without emitting, so callers
Expand Down
Loading
Loading