Implement the following plan:

# Plan: Trim Float Precision in JobClass Hint Messages

## Context
The `JobClassTagger` generates hint messages via Go `text/template` rendering. Template variables like `{{.cpu_load.avg}}`, `{{.mem_bw_pct}}`, `{{.memory_usage_pct}}` are float64 values that render with full precision (e.g., `12.345678901234`), making messages hard to read. We need to automatically round floats to a reasonable precision before template rendering.

## Approach
Add a helper function that recursively walks the env map and rounds all `float64` values to 2 decimal places. Create a rounded copy of the env for template execution only — rule evaluation continues to use full-precision values.

## File to Modify
- `internal/tagger/classifyJob.go`

## Changes

### 1. Add a `roundEnv` helper function
Recursively walks a `map[string]any`, and for any `float64` value, rounds it to 2 decimal places. For nested `map[string]any` or `map[string]float64` values, recurse. Returns a new map (does not mutate the original).

```go
func roundEnv(env map[string]any) map[string]any {
    rounded := make(map[string]any, len(env))
    for k, v := range env {
        switch val := v.(type) {
        case float64:
            rounded[k] = math.Round(val*100) / 100
        case map[string]any:
            rounded[k] = roundEnv(val)
        case map[string]float64:
            rm := make(map[string]float64, len(val))
            for mk, mv := range val {
                rm[mk] = math.Round(mv*100) / 100
            }
            rounded[k] = rm
        default:
            rounded[k] = v
        }
    }
    return rounded
}
```

### 2. Use rounded env for template execution in `Match()`
At line ~411, before `ri.hint.Execute(&msg, env)`, pass `roundEnv(env)` instead:

```go
if err := ri.hint.Execute(&msg, roundEnv(env)); err != nil {
```

### 3. Add `"math"` to imports

## Verification
- Run `go build ./...` to verify compilation
- Run `go test ./internal/tagger/...` to verify existing tests pass
- The test at line 88 uses `"hint": "High FLOPS: {{.avg_flops}}"` with a computed variable — this will exercise the rounding path

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/jan/.claude/projects/-Users-jan-prg-ClusterCockpit-cc-backend/71261f70-ba9c-4de4-9871-5921e6a371a1.jsonl


Let me read the file to understand the current code.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package tagger
     7→
     8→import (
     9→	"bytes"
    10→	"encoding/json"
    11→	"fmt"
    12→	"maps"
    13→	"os"
    14→	"path/filepath"
    15→	"strings"
    16→	"text/template"
    17→
    18→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    19→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    20→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    21→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    22→	"github.com/ClusterCockpit/cc-lib/v2/util"
    23→	"github.com/expr-lang/expr"
    24→	"github.com/expr-lang/expr/vm"
    25→)
    26→
    27→const (
    28→	// defaultJobClassConfigPath is the default path for job classification configuration
    29→	defaultJobClassConfigPath = "./var/tagger/jobclasses"
    30→	// tagTypeJobClass is the tag type identifier for job classification tags
    31→	tagTypeJobClass = "jobClass"
    32→	// jobClassConfigDirMatch is the directory name used for matching filesystem events
    33→	jobClassConfigDirMatch = "jobclasses"
    34→	// parametersFileName is the name of the parameters configuration file
    35→	parametersFileName = "parameters.json"
    36→)
    37→
    38→// Variable defines a named expression that can be computed and reused in rules.
    39→// Variables are evaluated before the main rule and their results are added to the environment.
    40→type Variable struct {
    41→	// Name is the variable identifier used in rule expressions
    42→	Name string `json:"name"`
    43→	// Expr is the expression to evaluate (must return a numeric value)
    44→	Expr string `json:"expr"`
    45→}
    46→
    47→type ruleVariable struct {
    48→	name string
    49→	expr *vm.Program
    50→}
    51→
    52→// RuleFormat defines the JSON structure for job classification rules.
    53→// Each rule specifies requirements, metrics to analyze, variables to compute,
    54→// and the final rule expression that determines if the job matches the classification.
    55→type RuleFormat struct {
    56→	// Name is a human-readable description of the rule
    57→	Name string `json:"name"`
    58→	// Tag is the classification tag to apply if the rule matches
    59→	Tag string `json:"tag"`
    60→	// Parameters are shared values referenced in the rule (e.g., thresholds)
    61→	Parameters []string `json:"parameters"`
    62→	// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
    63→	Metrics []string `json:"metrics"`
    64→	// Requirements are boolean expressions that must be true for the rule to apply
    65→	Requirements []string `json:"requirements"`
    66→	// Variables are computed values used in the rule expression
    67→	Variables []Variable `json:"variables"`
    68→	// Rule is the boolean expression that determines if the job matches
    69→	Rule string `json:"rule"`
    70→	// Hint is a template string that generates a message when the rule matches
    71→	Hint string `json:"hint"`
    72→}
    73→
    74→type ruleInfo struct {
    75→	env          map[string]any
    76→	metrics      []string
    77→	requirements []*vm.Program
    78→	variables    []ruleVariable
    79→	rule         *vm.Program
    80→	hint         *template.Template
    81→}
    82→
    83→// JobRepository defines the interface for job database operations needed by the tagger.
    84→// This interface allows for easier testing and decoupling from the concrete repository implementation.
    85→type JobRepository interface {
    86→	// HasTag checks if a job already has a specific tag
    87→	HasTag(jobID int64, tagType string, tagName string) bool
    88→	// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
    89→	AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
    90→	// UpdateMetadata updates job metadata with a key-value pair
    91→	UpdateMetadata(job *schema.Job, key, val string) (err error)
    92→}
    93→
    94→// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
    95→// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
    96→// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
    97→type JobClassTagger struct {
    98→	// rules maps classification tags to their compiled rule information
    99→	rules map[string]ruleInfo
   100→	// parameters are shared values (e.g., thresholds) used across multiple rules
   101→	parameters map[string]any
   102→	// tagType is the type of tag ("jobClass")
   103→	tagType string
   104→	// cfgPath is the path to watch for configuration changes
   105→	cfgPath string
   106→	// repo provides access to job database operations
   107→	repo JobRepository
   108→	// getStatistics retrieves job statistics for analysis
   109→	getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
   110→	// getMetricConfig retrieves metric configuration (limits) for a cluster
   111→	getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
   112→}
   113→
   114→func (t *JobClassTagger) prepareRule(b []byte, fns string) {
   115→	var rule RuleFormat
   116→	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
   117→		cclog.Warn("Error while decoding raw job meta json")
   118→		return
   119→	}
   120→
   121→	ri := ruleInfo{}
   122→	ri.env = make(map[string]any)
   123→	ri.metrics = make([]string, 0)
   124→	ri.requirements = make([]*vm.Program, 0)
   125→	ri.variables = make([]ruleVariable, 0)
   126→
   127→	// check if all required parameters are available
   128→	for _, p := range rule.Parameters {
   129→		param, ok := t.parameters[p]
   130→		if !ok {
   131→			cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
   132→			return
   133→		}
   134→		ri.env[p] = param
   135→	}
   136→
   137→	// set all required metrics
   138→	ri.metrics = append(ri.metrics, rule.Metrics...)
   139→
   140→	// compile requirements
   141→	for _, r := range rule.Requirements {
   142→		req, err := expr.Compile(r, expr.AsBool())
   143→		if err != nil {
   144→			cclog.Errorf("error compiling requirement %s: %#v", r, err)
   145→			return
   146→		}
   147→		ri.requirements = append(ri.requirements, req)
   148→	}
   149→
   150→	// compile variables
   151→	for _, v := range rule.Variables {
   152→		req, err := expr.Compile(v.Expr, expr.AsFloat64())
   153→		if err != nil {
   154→			cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
   155→			return
   156→		}
   157→		ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
   158→	}
   159→
   160→	// compile rule
   161→	exp, err := expr.Compile(rule.Rule, expr.AsBool())
   162→	if err != nil {
   163→		cclog.Errorf("error compiling rule %s: %#v", fns, err)
   164→		return
   165→	}
   166→	ri.rule = exp
   167→
   168→	// prepare hint template
   169→	ri.hint, err = template.New(fns).Parse(rule.Hint)
   170→	if err != nil {
   171→		cclog.Errorf("error processing template %s: %#v", fns, err)
   172→	}
   173→	cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))
   174→
   175→	t.rules[rule.Tag] = ri
   176→}
   177→
   178→// EventMatch checks if a filesystem event should trigger configuration reload.
   179→// It returns true if the event path contains "jobclasses".
   180→func (t *JobClassTagger) EventMatch(s string) bool {
   181→	return strings.Contains(s, jobClassConfigDirMatch)
   182→}
   183→
   184→// EventCallback is triggered when the configuration directory changes.
   185→// It reloads parameters and all rule files from the watched directory.
   186→// FIXME: Only process the file that caused the event
   187→func (t *JobClassTagger) EventCallback() {
   188→	files, err := os.ReadDir(t.cfgPath)
   189→	if err != nil {
   190→		cclog.Fatal(err)
   191→	}
   192→
   193→	t.rules = make(map[string]ruleInfo)
   194→
   195→	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
   196→	if util.CheckFileExists(parametersFile) {
   197→		cclog.Info("Merge parameters")
   198→		b, err := os.ReadFile(parametersFile)
   199→		if err != nil {
   200→			cclog.Warnf("prepareRule() > open file error: %v", err)
   201→		}
   202→
   203→		var paramTmp map[string]any
   204→		if err := json.NewDecoder(bytes.NewReader(b)).Decode(&paramTmp); err != nil {
   205→			cclog.Warn("Error while decoding parameters.json")
   206→		}
   207→
   208→		maps.Copy(t.parameters, paramTmp)
   209→	}
   210→
   211→	for _, fn := range files {
   212→		fns := fn.Name()
   213→		if fns != parametersFileName {
   214→			cclog.Debugf("Process: %s", fns)
   215→			filename := filepath.Join(t.cfgPath, fns)
   216→			b, err := os.ReadFile(filename)
   217→			if err != nil {
   218→				cclog.Warnf("prepareRule() > open file error: %v", err)
   219→				continue
   220→			}
   221→			t.prepareRule(b, fns)
   222→		}
   223→	}
   224→}
   225→
   226→func (t *JobClassTagger) initParameters() error {
   227→	cclog.Info("Initialize parameters")
   228→	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
   229→	b, err := os.ReadFile(parametersFile)
   230→	if err != nil {
   231→		cclog.Warnf("prepareRule() > open file error: %v", err)
   232→		return err
   233→	}
   234→
   235→	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
   236→		cclog.Warn("Error while decoding parameters.json")
   237→		return err
   238→	}
   239→
   240→	return nil
   241→}
   242→
   243→// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
   244→// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
   245→// dynamic configuration updates without restarting the application.
   246→// Returns an error if the configuration path does not exist or cannot be read.
   247→func (t *JobClassTagger) Register() error {
   248→	if t.cfgPath == "" {
   249→		t.cfgPath = defaultJobClassConfigPath
   250→	}
   251→	t.tagType = tagTypeJobClass
   252→	t.rules = make(map[string]ruleInfo)
   253→
   254→	if !util.CheckFileExists(t.cfgPath) {
   255→		return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
   256→	}
   257→
   258→	err := t.initParameters()
   259→	if err != nil {
   260→		cclog.Warnf("error reading parameters.json: %v", err)
   261→		return err
   262→	}
   263→
   264→	files, err := os.ReadDir(t.cfgPath)
   265→	if err != nil {
   266→		return fmt.Errorf("error reading jobclasses folder: %#v", err)
   267→	}
   268→
   269→	for _, fn := range files {
   270→		fns := fn.Name()
   271→		if fns != parametersFileName {
   272→			cclog.Infof("Process: %s", fns)
   273→			filename := filepath.Join(t.cfgPath, fns)
   274→
   275→			b, err := os.ReadFile(filename)
   276→			if err != nil {
   277→				cclog.Warnf("prepareRule() > open file error: %v", err)
   278→				continue
   279→			}
   280→			t.prepareRule(b, fns)
   281→		}
   282→	}
   283→
   284→	cclog.Infof("Setup file watch for %s", t.cfgPath)
   285→	util.AddListener(t.cfgPath, t)
   286→
   287→	t.repo = repository.GetJobRepository()
   288→	t.getStatistics = archive.GetStatistics
   289→	t.getMetricConfig = archive.GetMetricConfigSubCluster
   290→
   291→	return nil
   292→}
   293→
   294→// Match evaluates all classification rules against a job and applies matching tags.
   295→// It retrieves job statistics and metric configurations, then tests each rule's requirements
   296→// and main expression. For each matching rule, it:
   297→//   - Applies the classification tag to the job
   298→//   - Generates and stores a hint message based on the rule's template
   299→//
   300→// The function constructs an evaluation environment containing:
   301→//   - Job properties (duration, cores, nodes, state, etc.)
   302→//   - Metric statistics (min, max, avg) and their configured limits
   303→//   - Shared parameters defined in parameters.json
   304→//   - Computed variables from the rule definition
   305→//
   306→// Rules are evaluated in arbitrary order. Multiple rules can match and apply
   307→// their tags to the same job. Hint messages from all matching rules are collected
   308→// and stored as a combined message in the job metadata.
   309→func (t *JobClassTagger) Match(job *schema.Job) {
   310→	jobStats, err := t.getStatistics(job)
   311→	metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
   312→	cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
   313→	if err != nil {
   314→		cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
   315→		return
   316→	}
   317→
   318→	id := *job.ID
   319→	var messages []string
   320→
   321→	for tag, ri := range t.rules {
   322→		env := make(map[string]any)
   323→		maps.Copy(env, ri.env)
   324→		cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)
   325→
   326→		// Initialize environment
   327→		env["job"] = map[string]any{
   328→			"shared":   job.Shared,
   329→			"duration": job.Duration,
   330→			"numCores": job.NumHWThreads,
   331→			"numNodes": job.NumNodes,
   332→			"jobState": job.State,
   333→			"numAcc":   job.NumAcc,
   334→			"smt":      job.SMT,
   335→		}
   336→
   337→		// add metrics to env
   338→		skipRule := false
   339→		for _, m := range ri.metrics {
   340→			stats, ok := jobStats[m]
   341→			if !ok {
   342→				cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
   343→				skipRule = true
   344→				break
   345→			}
   346→			env[m] = map[string]any{
   347→				"min": stats.Min,
   348→				"max": stats.Max,
   349→				"avg": stats.Avg,
   350→				"limits": map[string]float64{
   351→					"peak":    metricsList[m].Peak,
   352→					"normal":  metricsList[m].Normal,
   353→					"caution": metricsList[m].Caution,
   354→					"alert":   metricsList[m].Alert,
   355→				},
   356→			}
   357→		}
   358→		if skipRule {
   359→			continue
   360→		}
   361→
   362→		// check rule requirements apply
   363→		requirementsMet := true
   364→		for _, r := range ri.requirements {
   365→			ok, err := expr.Run(r, env)
   366→			if err != nil {
   367→				cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
   368→				requirementsMet = false
   369→				break
   370→			}
   371→			if !ok.(bool) {
   372→				cclog.Debugf("requirement for rule %s not met", tag)
   373→				requirementsMet = false
   374→				break
   375→			}
   376→		}
   377→		if !requirementsMet {
   378→			continue
   379→		}
   380→
   381→		// evaluate rule variables
   382→		varError := false
   383→		for _, v := range ri.variables {
   384→			value, err := expr.Run(v.expr, env)
   385→			if err != nil {
   386→				cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
   387→				varError = true
   388→				break
   389→			}
   390→			env[v.name] = value
   391→		}
   392→		if varError {
   393→			continue
   394→		}
   395→
   396→		match, err := expr.Run(ri.rule, env)
   397→		if err != nil {
   398→			cclog.Errorf("error running rule %s: %#v", tag, err)
   399→			continue
   400→		}
   401→		if match.(bool) {
   402→			if !t.repo.HasTag(id, t.tagType, tag) {
   403→				if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
   404→					cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
   405→					continue
   406→				}
   407→			}
   408→
   409→			// process hint template
   410→			var msg bytes.Buffer
   411→			if err := ri.hint.Execute(&msg, env); err != nil {
   412→				cclog.Errorf("Template error: %s", err.Error())
   413→				continue
   414→			}
   415→			messages = append(messages, msg.String())
   416→		}
   417→	}
   418→
   419→	if len(messages) > 0 {
   420→		combined := strings.Join(messages, "\n")
   421→		if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
   422→			cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
   423→		}
   424→	}
   425→}
   426→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package tagger

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"strings"
	"text/template"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/expr-lang/expr"
	"github.com/expr-lang/expr/vm"
)

const (
	// defaultJobClassConfigPath is the default path for job classification configuration
	defaultJobClassConfigPath = "./var/tagger/jobclasses"
	// tagTypeJobClass is the tag type identifier for job classification tags
	tagTypeJobClass = "jobClass"
	// jobClassConfigDirMatch is the directory name used for matching filesystem events
	jobClassConfigDirMatch = "jobclasses"
	// parametersFileName is the name of the parameters configuration file
	parametersFileName = "parameters.json"
)

// Variable defines a named expression that can be computed and reused in rules.
// Variables are evaluated before the main rule and their results are added to the environment.
type Variable struct {
	// Name is the variable identifier used in rule expressions
	Name string `json:"name"`
	// Expr is the expression to evaluate (must return a numeric value)
	Expr string `json:"expr"`
}

type ruleVariable struct {
	name string
	expr *vm.Program
}

// RuleFormat defines the JSON structure for job classification rules.
// Each rule specifies requirements, metrics to analyze, variables to compute,
// and the final rule expression that determines if the job matches the classification.
type RuleFormat struct {
	// Name is a human-readable description of the rule
	Name string `json:"name"`
	// Tag is the classification tag to apply if the rule matches
	Tag string `json:"tag"`
	// Parameters are shared values referenced in the rule (e.g., thresholds)
	Parameters []string `json:"parameters"`
	// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
	Metrics []string `json:"metrics"`
	// Requirements are boolean expressions that must be true for the rule to apply
	Requirements []string `json:"requirements"`
	// Variables are computed values used in the rule expression
	Variables []Variable `json:"variables"`
	// Rule is the boolean expression that determines if the job matches
	Rule string `json:"rule"`
	// Hint is a template string that generates a message when the rule matches
	Hint string `json:"hint"`
}

type ruleInfo struct {
	env          map[string]any
	metrics      []string
	requirements []*vm.Program
	variables    []ruleVariable
	rule         *vm.Program
	hint         *template.Template
}

// JobRepository defines the interface for job database operations needed by the tagger.
// This interface allows for easier testing and decoupling from the concrete repository implementation.
type JobRepository interface {
	// HasTag checks if a job already has a specific tag
	HasTag(jobID int64, tagType string, tagName string) bool
	// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
	AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
	// UpdateMetadata updates job metadata with a key-value pair
	UpdateMetadata(job *schema.Job, key, val string) (err error)
}

// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
type JobClassTagger struct {
	// rules maps classification tags to their compiled rule information
	rules map[string]ruleInfo
	// parameters are shared values (e.g., thresholds) used across multiple rules
	parameters map[string]any
	// tagType is the type of tag ("jobClass")
	tagType string
	// cfgPath is the path to watch for configuration changes
	cfgPath string
	// repo provides access to job database operations
	repo JobRepository
	// getStatistics retrieves job statistics for analysis
	getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
	// getMetricConfig retrieves metric configuration (limits) for a cluster
	getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {
	var rule RuleFormat
	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
		cclog.Warn("Error while decoding raw job meta json")
		return
	}

	ri := ruleInfo{}
	ri.env = make(map[string]any)
	ri.metrics = make([]string, 0)
	ri.requirements = make([]*vm.Program, 0)
	ri.variables = make([]ruleVariable, 0)

	// check if all required parameters are available
	for _, p := range rule.Parameters {
		param, ok := t.parameters[p]
		if !ok {
			cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
			return
		}
		ri.env[p] = param
	}

	// set all required metrics
	ri.metrics = append(ri.metrics, rule.Metrics...)

	// compile requirements
	for _, r := range rule.Requirements {
		req, err := expr.Compile(r, expr.AsBool())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", r, err)
			return
		}
		ri.requirements = append(ri.requirements, req)
	}

	// compile variables
	for _, v := range rule.Variables {
		req, err := expr.Compile(v.Expr, expr.AsFloat64())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
			return
		}
		ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
	}

	// compile rule
	exp, err := expr.Compile(rule.Rule, expr.AsBool())
	if err != nil {
		cclog.Errorf("error compiling rule %s: %#v", fns, err)
		return
	}
	ri.rule = exp

	// prepare hint template
	ri.hint, err = template.New(fns).Parse(rule.Hint)
	if err != nil {
		cclog.Errorf("error processing template %s: %#v", fns, err)
	}
	cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))

	t.rules[rule.Tag] = ri
}

// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "jobclasses".
func (t *JobClassTagger) EventMatch(s string) bool {
	return strings.Contains(s, jobClassConfigDirMatch)
}

// EventCallback is triggered when the configuration directory changes.
// It reloads parameters and all rule files from the watched directory.
// FIXME: Only process the file that caused the event
func (t *JobClassTagger) EventCallback() {
	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		cclog.Fatal(err)
	}

	t.rules = make(map[string]ruleInfo)

	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	if util.CheckFileExists(parametersFile) {
		cclog.Info("Merge parameters")
		b, err := os.ReadFile(parametersFile)
		if err != nil {
			cclog.Warnf("prepareRule() > open file error: %v", err)
		}

		var paramTmp map[string]any
		if err := json.NewDecoder(bytes.NewReader(b)).Decode(&paramTmp); err != nil {
			cclog.Warn("Error while decoding parameters.json")
		}

		maps.Copy(t.parameters, paramTmp)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Debugf("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)
			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}
}

func (t *JobClassTagger) initParameters() error {
	cclog.Info("Initialize parameters")
	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	b, err := os.ReadFile(parametersFile)
	if err != nil {
		cclog.Warnf("prepareRule() > open file error: %v", err)
		return err
	}

	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
		cclog.Warn("Error while decoding parameters.json")
		return err
	}

	return nil
}

// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
// dynamic configuration updates without restarting the application.
// Returns an error if the configuration path does not exist or cannot be read.
func (t *JobClassTagger) Register() error {
	if t.cfgPath == "" {
		t.cfgPath = defaultJobClassConfigPath
	}
	t.tagType = tagTypeJobClass
	t.rules = make(map[string]ruleInfo)

	if !util.CheckFileExists(t.cfgPath) {
		return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
	}

	err := t.initParameters()
	if err != nil {
		cclog.Warnf("error reading parameters.json: %v", err)
		return err
	}

	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		return fmt.Errorf("error reading jobclasses folder: %#v", err)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Infof("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)

			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}

	cclog.Infof("Setup file watch for %s", t.cfgPath)
	util.AddListener(t.cfgPath, t)

	t.repo = repository.GetJobRepository()
	t.getStatistics = archive.GetStatistics
	t.getMetricConfig = archive.GetMetricConfigSubCluster

	return nil
}

// Match evaluates all classification rules against a job and applies matching tags.
// It retrieves job statistics and metric configurations, then tests each rule's requirements
// and main expression. For each matching rule, it:
//   - Applies the classification tag to the job
//   - Generates and stores a hint message based on the rule's template
//
// The function constructs an evaluation environment containing:
//   - Job properties (duration, cores, nodes, state, etc.)
//   - Metric statistics (min, max, avg) and their configured limits
//   - Shared parameters defined in parameters.json
//   - Computed variables from the rule definition
//
// Rules are evaluated in arbitrary order. Multiple rules can match and apply
// their tags to the same job. Hint messages from all matching rules are collected
// and stored as a combined message in the job metadata.
func (t *JobClassTagger) Match(job *schema.Job) {
	jobStats, err := t.getStatistics(job)
	metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
	cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
	if err != nil {
		cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
		return
	}

	id := *job.ID
	var messages []string

	for tag, ri := range t.rules {
		env := make(map[string]any)
		maps.Copy(env, ri.env)
		cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)

		// Initialize environment
		env["job"] = map[string]any{
			"shared":   job.Shared,
			"duration": job.Duration,
			"numCores": job.NumHWThreads,
			"numNodes": job.NumNodes,
			"jobState": job.State,
			"numAcc":   job.NumAcc,
			"smt":      job.SMT,
		}

		// add metrics to env
		skipRule := false
		for _, m := range ri.metrics {
			stats, ok := jobStats[m]
			if !ok {
				cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
				skipRule = true
				break
			}
			env[m] = map[string]any{
				"min": stats.Min,
				"max": stats.Max,
				"avg": stats.Avg,
				"limits": map[string]float64{
					"peak":    metricsList[m].Peak,
					"normal":  metricsList[m].Normal,
					"caution": metricsList[m].Caution,
					"alert":   metricsList[m].Alert,
				},
			}
		}
		if skipRule {
			continue
		}

		// check rule requirements apply
		requirementsMet := true
		for _, r := range ri.requirements {
			ok, err := expr.Run(r, env)
			if err != nil {
				cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
				requirementsMet = false
				break
			}
			if !ok.(bool) {
				cclog.Debugf("requirement for rule %s not met", tag)
				requirementsMet = false
				break
			}
		}
		if !requirementsMet {
			continue
		}

		// evaluate rule variables
		varError := false
		for _, v := range ri.variables {
			value, err := expr.Run(v.expr, env)
			if err != nil {
				cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
				varError = true
				break
			}
			env[v.name] = value
		}
		if varError {
			continue
		}

		match, err := expr.Run(ri.rule, env)
		if err != nil {
			cclog.Errorf("error running rule %s: %#v", tag, err)
			continue
		}
		if match.(bool) {
			if !t.repo.HasTag(id, t.tagType, tag) {
				if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
					cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
					continue
				}
			}

			// process hint template
			var msg bytes.Buffer
			if err := ri.hint.Execute(&msg, env); err != nil {
				cclog.Errorf("Template error: %s", err.Error())
				continue
			}
			messages = append(messages, msg.String())
		}
	}

	if len(messages) > 0 {
		combined := strings.Join(messages, "\n")
		if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
			cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
		}
	}
}


Now I'll implement the changes:


Edit

	"encoding/json"
	"fmt"
	"maps"
	"math"
	"os"


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/tagger/classifyJob.go has been updated successfully.

	"encoding/json"
	"fmt"
	"maps"
	"math"
	"os"

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package tagger

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"strings"
	"text/template"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/expr-lang/expr"
	"github.com/expr-lang/expr/vm"
)

const (
	// defaultJobClassConfigPath is the default path for job classification configuration
	defaultJobClassConfigPath = "./var/tagger/jobclasses"
	// tagTypeJobClass is the tag type identifier for job classification tags
	tagTypeJobClass = "jobClass"
	// jobClassConfigDirMatch is the directory name used for matching filesystem events
	jobClassConfigDirMatch = "jobclasses"
	// parametersFileName is the name of the parameters configuration file
	parametersFileName = "parameters.json"
)

// Variable defines a named expression that can be computed and reused in rules.
// Variables are evaluated before the main rule and their results are added to the environment.
type Variable struct {
	// Name is the variable identifier used in rule expressions
	Name string `json:"name"`
	// Expr is the expression to evaluate (must return a numeric value)
	Expr string `json:"expr"`
}

type ruleVariable struct {
	name string
	expr *vm.Program
}

// RuleFormat defines the JSON structure for job classification rules.
// Each rule specifies requirements, metrics to analyze, variables to compute,
// and the final rule expression that determines if the job matches the classification.
type RuleFormat struct {
	// Name is a human-readable description of the rule
	Name string `json:"name"`
	// Tag is the classification tag to apply if the rule matches
	Tag string `json:"tag"`
	// Parameters are shared values referenced in the rule (e.g., thresholds)
	Parameters []string `json:"parameters"`
	// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
	Metrics []string `json:"metrics"`
	// Requirements are boolean expressions that must be true for the rule to apply
	Requirements []string `json:"requirements"`
	// Variables are computed values used in the rule expression
	Variables []Variable `json:"variables"`
	// Rule is the boolean expression that determines if the job matches
	Rule string `json:"rule"`
	// Hint is a template string that generates a message when the rule matches
	Hint string `json:"hint"`
}

type ruleInfo struct {
	env          map[string]any
	metrics      []string
	requirements []*vm.Program
	variables    []ruleVariable
	rule         *vm.Program
	hint         *template.Template
}

// JobRepository defines the interface for job database operations needed by the tagger.
// This interface allows for easier testing and decoupling from the concrete repository implementation.
type JobRepository interface {
	// HasTag checks if a job already has a specific tag
	HasTag(jobID int64, tagType string, tagName string) bool
	// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
	AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
	// UpdateMetadata updates job metadata with a key-value pair
	UpdateMetadata(job *schema.Job, key, val string) (err error)
}

// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
type JobClassTagger struct {
	// rules maps classification tags to their compiled rule information
	rules map[string]ruleInfo
	// parameters are shared values (e.g., thresholds) used across multiple rules
	parameters map[string]any
	// tagType is the type of tag ("jobClass")
	tagType string
	// cfgPath is the path to watch for configuration changes
	cfgPath string
	// repo provides access to job database operations
	repo JobRepository
	// getStatistics retrieves job statistics for analysis
	getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
	// getMetricConfig retrieves metric configuration (limits) for a cluster
	getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {
	var rule RuleFormat
	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
		cclog.Warn("Error while decoding raw job meta json")
		return
	}

	ri := ruleInfo{}
	ri.env = make(map[string]any)
	ri.metrics = make([]string, 0)
	ri.requirements = make([]*vm.Program, 0)
	ri.variables = make([]ruleVariable, 0)

	// check if all required parameters are available
	for _, p := range rule.Parameters {
		param, ok := t.parameters[p]
		if !ok {
			cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
			return
		}
		ri.env[p] = param
	}

	// set all required metrics
	ri.metrics = append(ri.metrics, rule.Metrics...)

	// compile requirements
	for _, r := range rule.Requirements {
		req, err := expr.Compile(r, expr.AsBool())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", r, err)
			return
		}
		ri.requirements = append(ri.requirements, req)
	}

	// compile variables
	for _, v := range rule.Variables {
		req, err := expr.Compile(v.Expr, expr.AsFloat64())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
			return
		}
		ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
	}

	// compile rule
	exp, err := expr.Compile(rule.Rule, expr.AsBool())
	if err != nil {
		cclog.Errorf("error compiling rule %s: %#v", fns, err)
		return
	}
	ri.rule = exp

	// prepare hint template
	ri.hint, err = template.New(fns).Parse(rule.Hint)
	if err != nil {
		cclog.Errorf("error processing template %s: %#v", fns, err)
	}
	cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))

	t.rules[rule.Tag] = ri
}

// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "jobclasses".
func (t *JobClassTagger) EventMatch(s string) bool {
	return strings.Contains(s, jobClassConfigDirMatch)
}

// EventCallback is triggered when the configuration directory changes.
// It reloads parameters and all rule files from the watched directory.
// FIXME: Only process the file that caused the event
func (t *JobClassTagger) EventCallback() {
	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		cclog.Fatal(err)
	}

	t.rules = make(map[string]ruleInfo)

	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	if util.CheckFileExists(parametersFile) {
		cclog.Info("Merge parameters")
		b, err := os.ReadFile(parametersFile)
		if err != nil {
			cclog.Warnf("prepareRule() > open file error: %v", err)
		}

		var paramTmp map[string]any
		if err := json.NewDecoder(bytes.NewReader(b)).Decode(&paramTmp); err != nil {
			cclog.Warn("Error while decoding parameters.json")
		}

		maps.Copy(t.parameters, paramTmp)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Debugf("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)
			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}
}

func (t *JobClassTagger) initParameters() error {
	cclog.Info("Initialize parameters")
	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	b, err := os.ReadFile(parametersFile)
	if err != nil {
		cclog.Warnf("prepareRule() > open file error: %v", err)
		return err
	}

	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
		cclog.Warn("Error while decoding parameters.json")
		return err
	}

	return nil
}

// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
// dynamic configuration updates without restarting the application.
// Returns an error if the configuration path does not exist or cannot be read.
func (t *JobClassTagger) Register() error {
	if t.cfgPath == "" {
		t.cfgPath = defaultJobClassConfigPath
	}
	t.tagType = tagTypeJobClass
	t.rules = make(map[string]ruleInfo)

	if !util.CheckFileExists(t.cfgPath) {
		return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
	}

	err := t.initParameters()
	if err != nil {
		cclog.Warnf("error reading parameters.json: %v", err)
		return err
	}

	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		return fmt.Errorf("error reading jobclasses folder: %#v", err)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Infof("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)

			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}

	cclog.Infof("Setup file watch for %s", t.cfgPath)
	util.AddListener(t.cfgPath, t)

	t.repo = repository.GetJobRepository()
	t.getStatistics = archive.GetStatistics
	t.getMetricConfig = archive.GetMetricConfigSubCluster

	return nil
}

// Match evaluates all classification rules against a job and applies matching tags.
// It retrieves job statistics and metric configurations, then tests each rule's requirements
// and main expression. For each matching rule, it:
//   - Applies the classification tag to the job
//   - Generates and stores a hint message based on the rule's template
//
// The function constructs an evaluation environment containing:
//   - Job properties (duration, cores, nodes, state, etc.)
//   - Metric statistics (min, max, avg) and their configured limits
//   - Shared parameters defined in parameters.json
//   - Computed variables from the rule definition
//
// Rules are evaluated in arbitrary order. Multiple rules can match and apply
// their tags to the same job. Hint messages from all matching rules are collected
// and stored as a combined message in the job metadata.
func (t *JobClassTagger) Match(job *schema.Job) {
	jobStats, err := t.getStatistics(job)
	metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
	cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
	if err != nil {
		cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
		return
	}

	id := *job.ID
	var messages []string

	for tag, ri := range t.rules {
		env := make(map[string]any)
		maps.Copy(env, ri.env)
		cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)

		// Initialize environment
		env["job"] = map[string]any{
			"shared":   job.Shared,
			"duration": job.Duration,
			"numCores": job.NumHWThreads,
			"numNodes": job.NumNodes,
			"jobState": job.State,
			"numAcc":   job.NumAcc,
			"smt":      job.SMT,
		}

		// add metrics to env
		skipRule := false
		for _, m := range ri.metrics {
			stats, ok := jobStats[m]
			if !ok {
				cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
				skipRule = true
				break
			}
			env[m] = map[string]any{
				"min": stats.Min,
				"max": stats.Max,
				"avg": stats.Avg,
				"limits": map[string]float64{
					"peak":    metricsList[m].Peak,
					"normal":  metricsList[m].Normal,
					"caution": metricsList[m].Caution,
					"alert":   metricsList[m].Alert,
				},
			}
		}
		if skipRule {
			continue
		}

		// check rule requirements apply
		requirementsMet := true
		for _, r := range ri.requirements {
			ok, err := expr.Run(r, env)
			if err != nil {
				cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
				requirementsMet = false
				break
			}
			if !ok.(bool) {
				cclog.Debugf("requirement for rule %s not met", tag)
				requirementsMet = false
				break
			}
		}
		if !requirementsMet {
			continue
		}

		// evaluate rule variables
		varError := false
		for _, v := range ri.variables {
			value, err := expr.Run(v.expr, env)
			if err != nil {
				cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
				varError = true
				break
			}
			env[v.name] = value
		}
		if varError {
			continue
		}

		match, err := expr.Run(ri.rule, env)
		if err != nil {
			cclog.Errorf("error running rule %s: %#v", tag, err)
			continue
		}
		if match.(bool) {
			if !t.repo.HasTag(id, t.tagType, tag) {
				if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
					cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
					continue
				}
			}

			// process hint template
			var msg bytes.Buffer
			if err := ri.hint.Execute(&msg, env); err != nil {
				cclog.Errorf("Template error: %s", err.Error())
				continue
			}
			messages = append(messages, msg.String())
		}
	}

	if len(messages) > 0 {
		combined := strings.Join(messages, "\n")
		if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
			cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
		}
	}
}

"encoding/json"
"fmt"
"maps"
+  "math"
"os"
"path/filepath"
"strings"


func (t *JobClassTagger) prepareRule(b []byte, fns string) {

// roundEnv returns a copy of env with all float64 values rounded to 2 decimal places.
// Nested map[string]any and map[string]float64 values are recursed into.
func roundEnv(env map[string]any) map[string]any {
	rounded := make(map[string]any, len(env))
	for k, v := range env {
		switch val := v.(type) {
		case float64:
			rounded[k] = math.Round(val*100) / 100
		case map[string]any:
			rounded[k] = roundEnv(val)
		case map[string]float64:
			rm := make(map[string]float64, len(val))
			for mk, mv := range val {
				rm[mk] = math.Round(mv*100) / 100
			}
			rounded[k] = rm
		default:
			rounded[k] = v
		}
	}
	return rounded
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {


// roundEnv returns a copy of env with all float64 values rounded to 2 decimal places.
// Nested map[string]any and map[string]float64 values are recursed into.
func roundEnv(env map[string]any) map[string]any {
	rounded := make(map[string]any, len(env))
	for k, v := range env {
		switch val := v.(type) {
		case float64:
			rounded[k] = math.Round(val*100) / 100
		case map[string]any:
			rounded[k] = roundEnv(val)
		case map[string]float64:
			rm := make(map[string]float64, len(val))
			for mk, mv := range val {
				rm[mk] = math.Round(mv*100) / 100
			}
			rounded[k] = rm
		default:
			rounded[k] = v
		}
	}
	return rounded
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package tagger

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"math"
	"os"
	"path/filepath"
	"strings"
	"text/template"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/expr-lang/expr"
	"github.com/expr-lang/expr/vm"
)

const (
	// defaultJobClassConfigPath is the default path for job classification configuration
	defaultJobClassConfigPath = "./var/tagger/jobclasses"
	// tagTypeJobClass is the tag type identifier for job classification tags
	tagTypeJobClass = "jobClass"
	// jobClassConfigDirMatch is the directory name used for matching filesystem events
	jobClassConfigDirMatch = "jobclasses"
	// parametersFileName is the name of the parameters configuration file
	parametersFileName = "parameters.json"
)

// Variable defines a named expression that can be computed and reused in rules.
// Variables are evaluated before the main rule and their results are added to the environment.
type Variable struct {
	// Name is the variable identifier used in rule expressions
	Name string `json:"name"`
	// Expr is the expression to evaluate (must return a numeric value)
	Expr string `json:"expr"`
}

type ruleVariable struct {
	name string
	expr *vm.Program
}

// RuleFormat defines the JSON structure for job classification rules.
// Each rule specifies requirements, metrics to analyze, variables to compute,
// and the final rule expression that determines if the job matches the classification.
type RuleFormat struct {
	// Name is a human-readable description of the rule
	Name string `json:"name"`
	// Tag is the classification tag to apply if the rule matches
	Tag string `json:"tag"`
	// Parameters are shared values referenced in the rule (e.g., thresholds)
	Parameters []string `json:"parameters"`
	// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
	Metrics []string `json:"metrics"`
	// Requirements are boolean expressions that must be true for the rule to apply
	Requirements []string `json:"requirements"`
	// Variables are computed values used in the rule expression
	Variables []Variable `json:"variables"`
	// Rule is the boolean expression that determines if the job matches
	Rule string `json:"rule"`
	// Hint is a template string that generates a message when the rule matches
	Hint string `json:"hint"`
}

type ruleInfo struct {
	env          map[string]any
	metrics      []string
	requirements []*vm.Program
	variables    []ruleVariable
	rule         *vm.Program
	hint         *template.Template
}

// JobRepository defines the interface for job database operations needed by the tagger.
// This interface allows for easier testing and decoupling from the concrete repository implementation.
type JobRepository interface {
	// HasTag checks if a job already has a specific tag
	HasTag(jobID int64, tagType string, tagName string) bool
	// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
	AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
	// UpdateMetadata updates job metadata with a key-value pair
	UpdateMetadata(job *schema.Job, key, val string) (err error)
}

// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
type JobClassTagger struct {
	// rules maps classification tags to their compiled rule information
	rules map[string]ruleInfo
	// parameters are shared values (e.g., thresholds) used across multiple rules
	parameters map[string]any
	// tagType is the type of tag ("jobClass")
	tagType string
	// cfgPath is the path to watch for configuration changes
	cfgPath string
	// repo provides access to job database operations
	repo JobRepository
	// getStatistics retrieves job statistics for analysis
	getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
	// getMetricConfig retrieves metric configuration (limits) for a cluster
	getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {
	var rule RuleFormat
	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
		cclog.Warn("Error while decoding raw job meta json")
		return
	}

	ri := ruleInfo{}
	ri.env = make(map[string]any)
	ri.metrics = make([]string, 0)
	ri.requirements = make([]*vm.Program, 0)
	ri.variables = make([]ruleVariable, 0)

	// check if all required parameters are available
	for _, p := range rule.Parameters {
		param, ok := t.parameters[p]
		if !ok {
			cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
			return
		}
		ri.env[p] = param
	}

	// set all required metrics
	ri.metrics = append(ri.metrics, rule.Metrics...)

	// compile requirements
	for _, r := range rule.Requirements {
		req, err := expr.Compile(r, expr.AsBool())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", r, err)
			return
		}
		ri.requirements = append(ri.requirements, req)
	}

	// compile variables
	for _, v := range rule.Variables {
		req, err := expr.Compile(v.Expr, expr.AsFloat64())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
			return
		}
		ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
	}

	// compile rule
	exp, err := expr.Compile(rule.Rule, expr.AsBool())
	if err != nil {
		cclog.Errorf("error compiling rule %s: %#v", fns, err)
		return
	}
	ri.rule = exp

	// prepare hint template
	ri.hint, err = template.New(fns).Parse(rule.Hint)
	if err != nil {
		cclog.Errorf("error processing template %s: %#v", fns, err)
	}
	cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))

	t.rules[rule.Tag] = ri
}

// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "jobclasses".
func (t *JobClassTagger) EventMatch(s string) bool {
	return strings.Contains(s, jobClassConfigDirMatch)
}

// EventCallback is triggered when the configuration directory changes.
// It reloads parameters and all rule files from the watched directory.
// FIXME: Only process the file that caused the event
func (t *JobClassTagger) EventCallback() {
	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		cclog.Fatal(err)
	}

	t.rules = make(map[string]ruleInfo)

	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	if util.CheckFileExists(parametersFile) {
		cclog.Info("Merge parameters")
		b, err := os.ReadFile(parametersFile)
		if err != nil {
			cclog.Warnf("prepareRule() > open file error: %v", err)
		}

		var paramTmp map[string]any
		if err := json.NewDecoder(bytes.NewReader(b)).Decode(&paramTmp); err != nil {
			cclog.Warn("Error while decoding parameters.json")
		}

		maps.Copy(t.parameters, paramTmp)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Debugf("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)
			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}
}

func (t *JobClassTagger) initParameters() error {
	cclog.Info("Initialize parameters")
	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	b, err := os.ReadFile(parametersFile)
	if err != nil {
		cclog.Warnf("prepareRule() > open file error: %v", err)
		return err
	}

	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
		cclog.Warn("Error while decoding parameters.json")
		return err
	}

	return nil
}

// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
// dynamic configuration updates without restarting the application.
// Returns an error if the configuration path does not exist or cannot be read.
func (t *JobClassTagger) Register() error {
	if t.cfgPath == "" {
		t.cfgPath = defaultJobClassConfigPath
	}
	t.tagType = tagTypeJobClass
	t.rules = make(map[string]ruleInfo)

	if !util.CheckFileExists(t.cfgPath) {
		return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
	}

	err := t.initParameters()
	if err != nil {
		cclog.Warnf("error reading parameters.json: %v", err)
		return err
	}

	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		return fmt.Errorf("error reading jobclasses folder: %#v", err)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Infof("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)

			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}

	cclog.Infof("Setup file watch for %s", t.cfgPath)
	util.AddListener(t.cfgPath, t)

	t.repo = repository.GetJobRepository()
	t.getStatistics = archive.GetStatistics
	t.getMetricConfig = archive.GetMetricConfigSubCluster

	return nil
}

// Match evaluates all classification rules against a job and applies matching tags.
// It retrieves job statistics and metric configurations, then tests each rule's requirements
// and main expression. For each matching rule, it:
//   - Applies the classification tag to the job
//   - Generates and stores a hint message based on the rule's template
//
// The function constructs an evaluation environment containing:
//   - Job properties (duration, cores, nodes, state, etc.)
//   - Metric statistics (min, max, avg) and their configured limits
//   - Shared parameters defined in parameters.json
//   - Computed variables from the rule definition
//
// Rules are evaluated in arbitrary order. Multiple rules can match and apply
// their tags to the same job. Hint messages from all matching rules are collected
// and stored as a combined message in the job metadata.
func (t *JobClassTagger) Match(job *schema.Job) {
	jobStats, err := t.getStatistics(job)
	metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
	cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
	if err != nil {
		cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
		return
	}

	id := *job.ID
	var messages []string

	for tag, ri := range t.rules {
		env := make(map[string]any)
		maps.Copy(env, ri.env)
		cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)

		// Initialize environment
		env["job"] = map[string]any{
			"shared":   job.Shared,
			"duration": job.Duration,
			"numCores": job.NumHWThreads,
			"numNodes": job.NumNodes,
			"jobState": job.State,
			"numAcc":   job.NumAcc,
			"smt":      job.SMT,
		}

		// add metrics to env
		skipRule := false
		for _, m := range ri.metrics {
			stats, ok := jobStats[m]
			if !ok {
				cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
				skipRule = true
				break
			}
			env[m] = map[string]any{
				"min": stats.Min,
				"max": stats.Max,
				"avg": stats.Avg,
				"limits": map[string]float64{
					"peak":    metricsList[m].Peak,
					"normal":  metricsList[m].Normal,
					"caution": metricsList[m].Caution,
					"alert":   metricsList[m].Alert,
				},
			}
		}
		if skipRule {
			continue
		}

		// check rule requirements apply
		requirementsMet := true
		for _, r := range ri.requirements {
			ok, err := expr.Run(r, env)
			if err != nil {
				cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
				requirementsMet = false
				break
			}
			if !ok.(bool) {
				cclog.Debugf("requirement for rule %s not met", tag)
				requirementsMet = false
				break
			}
		}
		if !requirementsMet {
			continue
		}

		// evaluate rule variables
		varError := false
		for _, v := range ri.variables {
			value, err := expr.Run(v.expr, env)
			if err != nil {
				cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
				varError = true
				break
			}
			env[v.name] = value
		}
		if varError {
			continue
		}

		match, err := expr.Run(ri.rule, env)
		if err != nil {
			cclog.Errorf("error running rule %s: %#v", tag, err)
			continue
		}
		if match.(bool) {
			if !t.repo.HasTag(id, t.tagType, tag) {
				if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
					cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
					continue
				}
			}

			// process hint template
			var msg bytes.Buffer
			if err := ri.hint.Execute(&msg, env); err != nil {
				cclog.Errorf("Template error: %s", err.Error())
				continue
			}
			messages = append(messages, msg.String())
		}
	}

	if len(messages) > 0 {
		combined := strings.Join(messages, "\n")
		if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
			cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
		}
	}
}

+// roundEnv returns a copy of env with all float64 values rounded to 2 decimal places.

+// Nested map[string]any and map[string]float64 values are recursed into.

+func roundEnv(env map[string]any) map[string]any {

+  rounded := make(map[string]any, len(env))

+      rounded[k] = math.Round(val*100) / 100

+      rm := make(map[string]float64, len(val))

+        rm[mk] = math.Round(mv*100) / 100


if err := ri.hint.Execute(&msg, env); err != nil {

if err := ri.hint.Execute(&msg, roundEnv(env)); err != nil {


// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package tagger

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"math"
	"os"
	"path/filepath"
	"strings"
	"text/template"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/expr-lang/expr"
	"github.com/expr-lang/expr/vm"
)

const (
	// defaultJobClassConfigPath is the default path for job classification configuration
	defaultJobClassConfigPath = "./var/tagger/jobclasses"
	// tagTypeJobClass is the tag type identifier for job classification tags
	tagTypeJobClass = "jobClass"
	// jobClassConfigDirMatch is the directory name used for matching filesystem events
	jobClassConfigDirMatch = "jobclasses"
	// parametersFileName is the name of the parameters configuration file
	parametersFileName = "parameters.json"
)

// Variable defines a named expression that can be computed and reused in rules.
// Variables are evaluated before the main rule and their results are added to the environment.
type Variable struct {
	// Name is the variable identifier used in rule expressions
	Name string `json:"name"`
	// Expr is the expression to evaluate (must return a numeric value)
	Expr string `json:"expr"`
}

type ruleVariable struct {
	name string
	expr *vm.Program
}

// RuleFormat defines the JSON structure for job classification rules.
// Each rule specifies requirements, metrics to analyze, variables to compute,
// and the final rule expression that determines if the job matches the classification.
type RuleFormat struct {
	// Name is a human-readable description of the rule
	Name string `json:"name"`
	// Tag is the classification tag to apply if the rule matches
	Tag string `json:"tag"`
	// Parameters are shared values referenced in the rule (e.g., thresholds)
	Parameters []string `json:"parameters"`
	// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
	Metrics []string `json:"metrics"`
	// Requirements are boolean expressions that must be true for the rule to apply
	Requirements []string `json:"requirements"`
	// Variables are computed values used in the rule expression
	Variables []Variable `json:"variables"`
	// Rule is the boolean expression that determines if the job matches
	Rule string `json:"rule"`
	// Hint is a template string that generates a message when the rule matches
	Hint string `json:"hint"`
}

type ruleInfo struct {
	env          map[string]any
	metrics      []string
	requirements []*vm.Program
	variables    []ruleVariable
	rule         *vm.Program
	hint         *template.Template
}

// JobRepository defines the interface for job database operations needed by the tagger.
// This interface allows for easier testing and decoupling from the concrete repository implementation.
type JobRepository interface {
	// HasTag checks if a job already has a specific tag
	HasTag(jobID int64, tagType string, tagName string) bool
	// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
	AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error)
	// UpdateMetadata updates job metadata with a key-value pair
	UpdateMetadata(job *schema.Job, key, val string) (err error)
}

// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
// Rules are loaded from an external configuration directory and can be dynamically reloaded when files change.
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
type JobClassTagger struct {
	// rules maps classification tags to their compiled rule information
	rules map[string]ruleInfo
	// parameters are shared values (e.g., thresholds) used across multiple rules
	parameters map[string]any
	// tagType is the type of tag ("jobClass")
	tagType string
	// cfgPath is the path to watch for configuration changes
	cfgPath string
	// repo provides access to job database operations
	repo JobRepository
	// getStatistics retrieves job statistics for analysis
	getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
	// getMetricConfig retrieves metric configuration (limits) for a cluster
	getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
}

// roundEnv returns a copy of env with all float64 values rounded to 2 decimal places.
// Nested map[string]any and map[string]float64 values are recursed into.
func roundEnv(env map[string]any) map[string]any {
	rounded := make(map[string]any, len(env))
	for k, v := range env {
		switch val := v.(type) {
		case float64:
			rounded[k] = math.Round(val*100) / 100
		case map[string]any:
			rounded[k] = roundEnv(val)
		case map[string]float64:
			rm := make(map[string]float64, len(val))
			for mk, mv := range val {
				rm[mk] = math.Round(mv*100) / 100
			}
			rounded[k] = rm
		default:
			rounded[k] = v
		}
	}
	return rounded
}

func (t *JobClassTagger) prepareRule(b []byte, fns string) {
	var rule RuleFormat
	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&rule); err != nil {
		cclog.Warn("Error while decoding raw job meta json")
		return
	}

	ri := ruleInfo{}
	ri.env = make(map[string]any)
	ri.metrics = make([]string, 0)
	ri.requirements = make([]*vm.Program, 0)
	ri.variables = make([]ruleVariable, 0)

	// check if all required parameters are available
	for _, p := range rule.Parameters {
		param, ok := t.parameters[p]
		if !ok {
			cclog.Warnf("prepareRule() > missing parameter %s in rule %s", p, fns)
			return
		}
		ri.env[p] = param
	}

	// set all required metrics
	ri.metrics = append(ri.metrics, rule.Metrics...)

	// compile requirements
	for _, r := range rule.Requirements {
		req, err := expr.Compile(r, expr.AsBool())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", r, err)
			return
		}
		ri.requirements = append(ri.requirements, req)
	}

	// compile variables
	for _, v := range rule.Variables {
		req, err := expr.Compile(v.Expr, expr.AsFloat64())
		if err != nil {
			cclog.Errorf("error compiling requirement %s: %#v", v.Name, err)
			return
		}
		ri.variables = append(ri.variables, ruleVariable{name: v.Name, expr: req})
	}

	// compile rule
	exp, err := expr.Compile(rule.Rule, expr.AsBool())
	if err != nil {
		cclog.Errorf("error compiling rule %s: %#v", fns, err)
		return
	}
	ri.rule = exp

	// prepare hint template
	ri.hint, err = template.New(fns).Parse(rule.Hint)
	if err != nil {
		cclog.Errorf("error processing template %s: %#v", fns, err)
	}
	cclog.Infof("prepareRule() > processing %s with %d requirements and %d variables", fns, len(ri.requirements), len(ri.variables))

	t.rules[rule.Tag] = ri
}

// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "jobclasses".
func (t *JobClassTagger) EventMatch(s string) bool {
	return strings.Contains(s, jobClassConfigDirMatch)
}

// EventCallback is triggered when the configuration directory changes.
// It reloads parameters and all rule files from the watched directory.
// FIXME: Only process the file that caused the event
func (t *JobClassTagger) EventCallback() {
	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		cclog.Fatal(err)
	}

	t.rules = make(map[string]ruleInfo)

	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	if util.CheckFileExists(parametersFile) {
		cclog.Info("Merge parameters")
		b, err := os.ReadFile(parametersFile)
		if err != nil {
			cclog.Warnf("prepareRule() > open file error: %v", err)
		}

		var paramTmp map[string]any
		if err := json.NewDecoder(bytes.NewReader(b)).Decode(&paramTmp); err != nil {
			cclog.Warn("Error while decoding parameters.json")
		}

		maps.Copy(t.parameters, paramTmp)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Debugf("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)
			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}
}

func (t *JobClassTagger) initParameters() error {
	cclog.Info("Initialize parameters")
	parametersFile := filepath.Join(t.cfgPath, parametersFileName)
	b, err := os.ReadFile(parametersFile)
	if err != nil {
		cclog.Warnf("prepareRule() > open file error: %v", err)
		return err
	}

	if err := json.NewDecoder(bytes.NewReader(b)).Decode(&t.parameters); err != nil {
		cclog.Warn("Error while decoding parameters.json")
		return err
	}

	return nil
}

// Register initializes the JobClassTagger by loading parameters and classification rules from external folder.
// It sets up a file watch on ./var/tagger/jobclasses if it exists, allowing for
// dynamic configuration updates without restarting the application.
// Returns an error if the configuration path does not exist or cannot be read.
func (t *JobClassTagger) Register() error {
	if t.cfgPath == "" {
		t.cfgPath = defaultJobClassConfigPath
	}
	t.tagType = tagTypeJobClass
	t.rules = make(map[string]ruleInfo)

	if !util.CheckFileExists(t.cfgPath) {
		return fmt.Errorf("configuration path does not exist: %s", t.cfgPath)
	}

	err := t.initParameters()
	if err != nil {
		cclog.Warnf("error reading parameters.json: %v", err)
		return err
	}

	files, err := os.ReadDir(t.cfgPath)
	if err != nil {
		return fmt.Errorf("error reading jobclasses folder: %#v", err)
	}

	for _, fn := range files {
		fns := fn.Name()
		if fns != parametersFileName {
			cclog.Infof("Process: %s", fns)
			filename := filepath.Join(t.cfgPath, fns)

			b, err := os.ReadFile(filename)
			if err != nil {
				cclog.Warnf("prepareRule() > open file error: %v", err)
				continue
			}
			t.prepareRule(b, fns)
		}
	}

	cclog.Infof("Setup file watch for %s", t.cfgPath)
	util.AddListener(t.cfgPath, t)

	t.repo = repository.GetJobRepository()
	t.getStatistics = archive.GetStatistics
	t.getMetricConfig = archive.GetMetricConfigSubCluster

	return nil
}

// Match evaluates all classification rules against a job and applies matching tags.
// It retrieves job statistics and metric configurations, then tests each rule's requirements
// and main expression. For each matching rule, it:
//   - Applies the classification tag to the job
//   - Generates and stores a hint message based on the rule's template
//
// The function constructs an evaluation environment containing:
//   - Job properties (duration, cores, nodes, state, etc.)
//   - Metric statistics (min, max, avg) and their configured limits
//   - Shared parameters defined in parameters.json
//   - Computed variables from the rule definition
//
// Rules are evaluated in arbitrary order. Multiple rules can match and apply
// their tags to the same job. Hint messages from all matching rules are collected
// and stored as a combined message in the job metadata.
func (t *JobClassTagger) Match(job *schema.Job) {
	jobStats, err := t.getStatistics(job)
	metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
	cclog.Debugf("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
	if err != nil {
		cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
		return
	}

	id := *job.ID
	var messages []string

	for tag, ri := range t.rules {
		env := make(map[string]any)
		maps.Copy(env, ri.env)
		cclog.Debugf("Try to match rule %s for job %d", tag, job.JobID)

		// Initialize environment
		env["job"] = map[string]any{
			"shared":   job.Shared,
			"duration": job.Duration,
			"numCores": job.NumHWThreads,
			"numNodes": job.NumNodes,
			"jobState": job.State,
			"numAcc":   job.NumAcc,
			"smt":      job.SMT,
		}

		// add metrics to env
		skipRule := false
		for _, m := range ri.metrics {
			stats, ok := jobStats[m]
			if !ok {
				cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
				skipRule = true
				break
			}
			env[m] = map[string]any{
				"min": stats.Min,
				"max": stats.Max,
				"avg": stats.Avg,
				"limits": map[string]float64{
					"peak":    metricsList[m].Peak,
					"normal":  metricsList[m].Normal,
					"caution": metricsList[m].Caution,
					"alert":   metricsList[m].Alert,
				},
			}
		}
		if skipRule {
			continue
		}

		// check rule requirements apply
		requirementsMet := true
		for _, r := range ri.requirements {
			ok, err := expr.Run(r, env)
			if err != nil {
				cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
				requirementsMet = false
				break
			}
			if !ok.(bool) {
				cclog.Debugf("requirement for rule %s not met", tag)
				requirementsMet = false
				break
			}
		}
		if !requirementsMet {
			continue
		}

		// evaluate rule variables
		varError := false
		for _, v := range ri.variables {
			value, err := expr.Run(v.expr, env)
			if err != nil {
				cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
				varError = true
				break
			}
			env[v.name] = value
		}
		if varError {
			continue
		}

		match, err := expr.Run(ri.rule, env)
		if err != nil {
			cclog.Errorf("error running rule %s: %#v", tag, err)
			continue
		}
		if match.(bool) {
			if !t.repo.HasTag(id, t.tagType, tag) {
				if _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag); err != nil {
					cclog.Errorf("failed to add tag '%s' to job %d: %v", tag, id, err)
					continue
				}
			}

			// process hint template
			var msg bytes.Buffer
			if err := ri.hint.Execute(&msg, env); err != nil {
				cclog.Errorf("Template error: %s", err.Error())
				continue
			}
			messages = append(messages, msg.String())
		}
	}

	if len(messages) > 0 {
		combined := strings.Join(messages, "\n")
		if err := t.repo.UpdateMetadata(job, "message", combined); err != nil {
			cclog.Errorf("failed to update metadata for job %d: %v", *job.ID, err)
		}
	}
}

-      if err := ri.hint.Execute(&msg, env); err != nil {

+      if err := ri.hint.Execute(&msg, roundEnv(env)); err != nil {


Now let me build and test:


Bash

Build all packages


Run tagger tests


ok  	github.com/ClusterCockpit/cc-backend/internal/tagger	0.531s


Build succeeds and all tagger tests pass. The changes:

1. Added `roundEnv()` helper that recursively rounds all `float64` values to 2 decimal places in a map copy
2. Used `roundEnv(env)` instead of `env` when executing hint templates (line 430)
3. Rule evaluation still uses full-precision values — only template rendering gets rounded values