Implement the following plan:

# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)

## Context

The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).

## Root Cause (in cc-lib)

`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:

### Bug 1 — The crash (line 327)
- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)
- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname
- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data

### Bug 2 — Logic error (lines 318-323)
- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)
- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics

## Fix (in cc-lib `schema/metrics.go`)

Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):

```go
// Before (BUGGY):
n, m := 0, len(jm.Series[0].Data)
for _, series := range jm.Series {
    ...
}
i, data := 0, make([]Float, len(series[0].Data))
for ; i < m; i++ {
    x := Float(0.0)
    for _, series := range jm.Series {
        x += series.Data[i]
    }
    data[i] = x
}

// After (FIXED):
n, m := 0, len(series[0].Data)
for _, s := range series {
    if len(s.Data) > n {
        n = len(s.Data)
    }
    if len(s.Data) < m {
        m = len(s.Data)
    }
}
i, data := 0, make([]Float, n)
for ; i < m; i++ {
    x := Float(0.0)
    for _, s := range series {
        x += s.Data[i]
    }
    data[i] = x
}
```

Key changes:
1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`
2. `data` sized to `n` (max length for this host), not `series[0].Data` length
3. Summation iterates local `series`, not `jm.Series`

## Where to Apply

The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:

1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend
2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug

**Recommended: Option 1** — fix cc-lib.

## Verification

- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation
- Integration: archive a job with mixed-length metric data

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/0075d51a-8bd8-430a-9f74-fa37f9252c52.jsonl


Let me find the cc-lib source and the buggy code.


Bash

grep -r "cc-lib" /Users/jan/prg/ClusterCockpit/cc-backend/go.mod | head -5

Find cc-lib dependency path


/Users/jan/prg/ClusterCockpit/cc-backend/go.mod:	github.com/ClusterCockpit/cc-lib/v2 v2.8.2


   290→	for _, series := range jm.Series {
   291→		hosts[series.Hostname] = append(hosts[series.Hostname], series)
   292→	}
   293→
   294→	nodeJm := &JobMetric{
   295→		Unit:     jm.Unit,
   296→		Timestep: jm.Timestep,
   297→		Series:   make([]Series, 0, len(hosts)),
   298→	}
   299→	for hostname, series := range hosts {
   300→		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
   301→		for _, series := range series {
   302→			sum += series.Statistics.Avg
   303→			min = math.Min(min, series.Statistics.Min)
   304→			max = math.Max(max, series.Statistics.Max)
   305→		}
   306→
   307→		n, m := 0, len(jm.Series[0].Data)
   308→		for _, series := range jm.Series {
   309→			if len(series.Data) > n {
   310→				n = len(series.Data)
   311→			}
   312→			if len(series.Data) < m {
   313→				m = len(series.Data)
   314→			}
   315→		}
   316→
   317→		i, data := 0, make([]Float, len(series[0].Data))
   318→		for ; i < m; i++ {
   319→			x := Float(0.0)
   320→			for _, series := range jm.Series {
   321→				x += series.Data[i]
   322→			}
   323→			data[i] = x
   324→		}
   325→
   326→		for ; i < n; i++ {
   327→			data[i] = NaN
   328→		}
   329→
   330→		nodeJm.Series = append(nodeJm.Series, Series{
   331→			Hostname:   hostname,
   332→			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
   333→			Data:       data,
   334→		})
   335→	}
   336→
   337→	scopes[MetricScopeNode] = nodeJm
   338→	return true
   339→}

	for _, series := range jm.Series {
		hosts[series.Hostname] = append(hosts[series.Hostname], series)
	}

	nodeJm := &JobMetric{
		Unit:     jm.Unit,
		Timestep: jm.Timestep,
		Series:   make([]Series, 0, len(hosts)),
	}
	for hostname, series := range hosts {
		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
		for _, series := range series {
			sum += series.Statistics.Avg
			min = math.Min(min, series.Statistics.Min)
			max = math.Max(max, series.Statistics.Max)
		}

		n, m := 0, len(jm.Series[0].Data)
		for _, series := range jm.Series {
			if len(series.Data) > n {
				n = len(series.Data)
			}
			if len(series.Data) < m {
				m = len(series.Data)
			}
		}

		i, data := 0, make([]Float, len(series[0].Data))
		for ; i < m; i++ {
			x := Float(0.0)
			for _, series := range jm.Series {
				x += series.Data[i]
			}
			data[i] = x
		}

		for ; i < n; i++ {
			data[i] = NaN
		}

		nodeJm.Series = append(nodeJm.Series, Series{
			Hostname:   hostname,
			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
			Data:       data,
		})
	}

	scopes[MetricScopeNode] = nodeJm
	return true
}


Now I can see the exact buggy code. Let me apply the fix.


Edit

		n, m := 0, len(jm.Series[0].Data)
		for _, series := range jm.Series {
			if len(series.Data) > n {
				n = len(series.Data)
			}
			if len(series.Data) < m {
				m = len(series.Data)
			}
		}

		i, data := 0, make([]Float, len(series[0].Data))
		for ; i < m; i++ {
			x := Float(0.0)
			for _, series := range jm.Series {
				x += series.Data[i]
			}
			data[i] = x
		}

		n, m := 0, len(series[0].Data)
		for _, s := range series {
			if len(s.Data) > n {
				n = len(s.Data)
			}
			if len(s.Data) < m {
				m = len(s.Data)
			}
		}

		i, data := 0, make([]Float, n)
		for ; i < m; i++ {
			x := Float(0.0)
			for _, s := range series {
				x += s.Data[i]
			}
			data[i] = x
		}


The file /Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go has been updated successfully.

		n, m := 0, len(series[0].Data)
		for _, s := range series {
			if len(s.Data) > n {
				n = len(s.Data)
			}
			if len(s.Data) < m {
				m = len(s.Data)
			}
		}

		i, data := 0, make([]Float, n)
		for ; i < m; i++ {
			x := Float(0.0)
			for _, s := range series {
				x += s.Data[i]
			}
			data[i] = x
		}

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

package schema

import (
	"fmt"
	"io"
	"math"
	"sort"
	"unsafe"

	"github.com/ClusterCockpit/cc-lib/v2/util"
)

// JobData maps metric names to their data organized by scope.
// Structure: map[metricName]map[scope]*JobMetric
//
// For example: jobData["cpu_load"][MetricScopeNode] contains node-level CPU load data.
// This structure allows efficient lookup of metrics at different hierarchical levels.
type JobData map[string]map[MetricScope]*JobMetric

// ScopedJobStats maps metric names to statistical summaries organized by scope.
// Structure: map[metricName]map[scope][]*ScopedStats
//
// Used to store pre-computed statistics without the full time series data,
// reducing memory footprint when only aggregated values are needed.
type ScopedJobStats map[string]map[MetricScope][]*ScopedStats

// JobMetric contains time series data and statistics for a single metric.
//
// The Series field holds time series data from individual nodes/hardware components,
// while StatisticsSeries provides aggregated statistics across all series over time.
type JobMetric struct {
	StatisticsSeries *StatsSeries `json:"statisticsSeries,omitempty"` // Aggregated statistics over time
	Unit             Unit         `json:"unit"`                       // Unit of measurement
	Series           []Series     `json:"series"`                     // Individual time series data
	Timestep         int          `json:"timestep"`                   // Sampling interval in seconds
}

// Series represents a single time series of metric measurements.
//
// Each series corresponds to one source (e.g., one node, one core) identified by Hostname and optional ID.
// The Data field contains the time-ordered measurements, and Statistics provides min/avg/max summaries.
type Series struct {
	ID         *string          `json:"id,omitempty"` // Optional ID (e.g., core ID, GPU ID)
	Hostname   string           `json:"hostname"`     // Source hostname
	Data       []Float          `json:"data"`         // Time series measurements
	Statistics MetricStatistics `json:"statistics"`   // Statistical summary (min/avg/max)
}

// ScopedStats contains statistical summaries for a specific scope (e.g., one node, one socket).
// Used when full time series data isn't needed, only the aggregated statistics.
type ScopedStats struct {
	Hostname string            `json:"hostname"`     // Source hostname
	ID       *string           `json:"id,omitempty"` // Optional scope ID
	Data     *MetricStatistics `json:"data"`         // Statistical summary
}

// MetricStatistics holds statistical summary values for metric data.
// Provides the common statistical aggregations used throughout ClusterCockpit.
type MetricStatistics struct {
	Avg float64 `json:"avg"` // Average/mean value
	Min float64 `json:"min"` // Minimum value
	Max float64 `json:"max"` // Maximum value
}

// StatsSeries contains aggregated statistics across multiple time series over time.
//
// Instead of storing individual series, this provides statistical summaries at each time step.
// For example, at time t, Mean[t] is the average value across all series at that time.
// Percentiles provides specified percentile values at each time step.
type StatsSeries struct {
	Percentiles map[int][]Float `json:"percentiles,omitempty"` // Percentile values over time (e.g., 10th, 50th, 90th)
	Mean        []Float         `json:"mean"`                  // Mean values over time
	Median      []Float         `json:"median"`                // Median values over time
	Min         []Float         `json:"min"`                   // Minimum values over time
	Max         []Float         `json:"max"`                   // Maximum values over time
}

// MetricScope defines the hierarchical level at which a metric is measured.
//
// Scopes form a hierarchy from coarse-grained (node) to fine-grained (hwthread/accelerator):
//
//	node > socket > memoryDomain > core > hwthread
//	accelerator is a special scope at the same level as hwthread
//
// The scopePrecedence map defines numeric ordering for scope comparisons,
// which is used when aggregating metrics across different scopes.
type MetricScope string

const (
	MetricScopeInvalid MetricScope = "invalid_scope"

	MetricScopeNode         MetricScope = "node"
	MetricScopeSocket       MetricScope = "socket"
	MetricScopeMemoryDomain MetricScope = "memoryDomain"
	MetricScopeCore         MetricScope = "core"
	MetricScopeHWThread     MetricScope = "hwthread"

	// TODO: Add Job and Application scopes

	MetricScopeAccelerator MetricScope = "accelerator"
)

var metricScopeGranularity map[MetricScope]int = map[MetricScope]int{
	MetricScopeNode:         10,
	MetricScopeSocket:       5,
	MetricScopeMemoryDomain: 4,
	MetricScopeCore:         3,
	MetricScopeHWThread:     2,
	/* Special-Case Accelerator
	 * -> No conversion possible if native scope is HWTHREAD
	 * -> Therefore needs to be less than HWTREAD, else max() would return unhandled case
	 * -> If nativeScope is accelerator, accelerator metrics return correctly
	 */
	MetricScopeAccelerator: 1,

	MetricScopeInvalid: -1,
}

func (e *MetricScope) LT(other MetricScope) bool {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	return a < b
}

func (e *MetricScope) LTE(other MetricScope) bool {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	return a <= b
}

func (e *MetricScope) Max(other MetricScope) MetricScope {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	if a > b {
		return *e
	}
	return other
}

func (e *MetricScope) UnmarshalGQL(v any) error {
	str, ok := v.(string)
	if !ok {
		return fmt.Errorf("SCHEMA/METRICS > enums must be strings")
	}

	*e = MetricScope(str)
	if !e.Valid() {
		return fmt.Errorf("SCHEMA/METRICS > %s is not a valid MetricScope", str)
	}
	return nil
}

func (e MetricScope) MarshalGQL(w io.Writer) {
	fmt.Fprintf(w, "\"%s\"", e)
}

func (e MetricScope) Valid() bool {
	gran, ok := metricScopeGranularity[e]
	return ok && gran > 0
}

func (jd *JobData) Size() int {
	n := 128
	for _, scopes := range *jd {
		for _, metric := range scopes {
			if metric.StatisticsSeries != nil {
				n += len(metric.StatisticsSeries.Max)
				n += len(metric.StatisticsSeries.Mean)
				n += len(metric.StatisticsSeries.Median)
				n += len(metric.StatisticsSeries.Min)
			}

			for _, series := range metric.Series {
				n += len(series.Data)
			}
		}
	}
	return n * int(unsafe.Sizeof(Float(0)))
}

const smooth bool = false

func (jm *JobMetric) AddStatisticsSeries() {
	if jm.StatisticsSeries != nil || len(jm.Series) < 4 {
		return
	}

	n, m := 0, len(jm.Series[0].Data)
	for _, series := range jm.Series {
		if len(series.Data) > n {
			n = len(series.Data)
		}
		if len(series.Data) < m {
			m = len(series.Data)
		}
	}

	// mean := make([]Float, n)
	min, median, max := make([]Float, n), make([]Float, n), make([]Float, n)
	i := 0
	for ; i < m; i++ {
		seriesCount := len(jm.Series)
		// ssum := 0.0
		smin, smed, smax := math.MaxFloat32, make([]float64, seriesCount), -math.MaxFloat32
		notnan := 0
		for j := range seriesCount {
			x := float64(jm.Series[j].Data[i])
			if math.IsNaN(x) {
				continue
			}

			notnan += 1
			// ssum += x
			smed[j] = x
			smin = math.Min(smin, x)
			smax = math.Max(smax, x)
		}

		if notnan < 3 {
			min[i] = NaN
			// mean[i] = NaN
			median[i] = NaN
			max[i] = NaN
		} else {
			min[i] = Float(smin)
			// mean[i] = Float(ssum / float64(notnan))
			max[i] = Float(smax)

			medianRaw, err := util.Median(smed)
			if err != nil {
				median[i] = NaN
			} else {
				median[i] = Float(medianRaw)
			}
		}
	}

	for ; i < n; i++ {
		min[i] = NaN
		// mean[i] = NaN
		median[i] = NaN
		max[i] = NaN
	}

	if smooth {
		for i := 2; i < len(median)-2; i++ {
			if min[i].IsNaN() {
				continue
			}

			min[i] = (min[i-2] + min[i-1] + min[i] + min[i+1] + min[i+2]) / 5
			max[i] = (max[i-2] + max[i-1] + max[i] + max[i+1] + max[i+2]) / 5
			// mean[i] = (mean[i-2] + mean[i-1] + mean[i] + mean[i+1] + mean[i+2]) / 5
			// Reduce Median further
			smoothRaw := []float64{float64(median[i-2]), float64(median[i-1]), float64(median[i]), float64(median[i+1]), float64(median[i+2])}
			smoothMedian, err := util.Median(smoothRaw)
			if err != nil {
				median[i] = NaN
			} else {
				median[i] = Float(smoothMedian)
			}
		}
	}

	jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
}

func (jd *JobData) AddNodeScope(metric string) bool {
	scopes, ok := (*jd)[metric]
	if !ok {
		return false
	}

	maxScope := MetricScopeInvalid
	for scope := range scopes {
		maxScope = maxScope.Max(scope)
	}

	if maxScope == MetricScopeInvalid || maxScope == MetricScopeNode {
		return false
	}

	jm := scopes[maxScope]
	hosts := make(map[string][]Series, 32)
	for _, series := range jm.Series {
		hosts[series.Hostname] = append(hosts[series.Hostname], series)
	}

	nodeJm := &JobMetric{
		Unit:     jm.Unit,
		Timestep: jm.Timestep,
		Series:   make([]Series, 0, len(hosts)),
	}
	for hostname, series := range hosts {
		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
		for _, series := range series {
			sum += series.Statistics.Avg
			min = math.Min(min, series.Statistics.Min)
			max = math.Max(max, series.Statistics.Max)
		}

		n, m := 0, len(jm.Series[0].Data)
		for _, series := range jm.Series {
			if len(series.Data) > n {
				n = len(series.Data)
			}
			if len(series.Data) < m {
				m = len(series.Data)
			}
		}

		i, data := 0, make([]Float, len(series[0].Data))
		for ; i < m; i++ {
			x := Float(0.0)
			for _, series := range jm.Series {
				x += series.Data[i]
			}
			data[i] = x
		}

		for ; i < n; i++ {
			data[i] = NaN
		}

		nodeJm.Series = append(nodeJm.Series, Series{
			Hostname:   hostname,
			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
			Data:       data,
		})
	}

	scopes[MetricScopeNode] = nodeJm
	return true
}

func (jd *JobData) RoundMetricStats() {
	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
	for _, scopes := range *jd {
		for _, jm := range scopes {
			for index := range jm.Series {
				jm.Series[index].Statistics = MetricStatistics{
					Avg: (math.Round(jm.Series[index].Statistics.Avg*100) / 100),
					Min: (math.Round(jm.Series[index].Statistics.Min*100) / 100),
					Max: (math.Round(jm.Series[index].Statistics.Max*100) / 100),
				}
			}
		}
	}
}

func (sjs *ScopedJobStats) RoundScopedMetricStats() {
	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
	for _, scopes := range *sjs {
		for _, stats := range scopes {
			for index := range stats {
				roundedStats := MetricStatistics{
					Avg: (math.Round(stats[index].Data.Avg*100) / 100),
					Min: (math.Round(stats[index].Data.Min*100) / 100),
					Max: (math.Round(stats[index].Data.Max*100) / 100),
				}
				stats[index].Data = &roundedStats
			}
		}
	}
}

func (jm *JobMetric) AddPercentiles(ps []int) bool {
	if jm.StatisticsSeries == nil {
		jm.AddStatisticsSeries()
	}

	if len(jm.Series) < 3 {
		return false
	}

	if jm.StatisticsSeries.Percentiles == nil {
		jm.StatisticsSeries.Percentiles = make(map[int][]Float, len(ps))
	}

	n := 0
	for _, series := range jm.Series {
		if len(series.Data) > n {
			n = len(series.Data)
		}
	}

	data := make([][]float64, n)
	for i := range n {
		vals := make([]float64, 0, len(jm.Series))
		for _, series := range jm.Series {
			if i < len(series.Data) {
				vals = append(vals, float64(series.Data[i]))
			}
		}

		sort.Float64s(vals)
		data[i] = vals
	}

	for _, p := range ps {
		if p < 1 || p > 99 {
			panic("SCHEMA/METRICS > invalid percentile")
		}

		if _, ok := jm.StatisticsSeries.Percentiles[p]; ok {
			continue
		}

		percentiles := make([]Float, n)
		for i := range n {
			sorted := data[i]
			percentiles[i] = Float(sorted[(len(sorted)*p)/100])
		}

		jm.StatisticsSeries.Percentiles[p] = percentiles
	}

	return true
}

-    i, data := 0, make([]Float, len(series[0].Data))

-      for _, series := range jm.Series {


Now let me verify the fix looks correct and check if there are existing tests.


   306→
   307→		n, m := 0, len(series[0].Data)
   308→		for _, s := range series {
   309→			if len(s.Data) > n {
   310→				n = len(s.Data)
   311→			}
   312→			if len(s.Data) < m {
   313→				m = len(s.Data)
   314→			}
   315→		}
   316→
   317→		i, data := 0, make([]Float, n)
   318→		for ; i < m; i++ {
   319→			x := Float(0.0)
   320→			for _, s := range series {
   321→				x += s.Data[i]
   322→			}
   323→			data[i] = x
   324→		}
   325→
   326→		for ; i < n; i++ {
   327→			data[i] = NaN
   328→		}
   329→
   330→		nodeJm.Series = append(nodeJm.Series, Series{
   331→			Hostname:   hostname,
   332→			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
   333→			Data:       data,
   334→		})
   335→	}

		n, m := 0, len(series[0].Data)
		for _, s := range series {
			if len(s.Data) > n {
				n = len(s.Data)
			}
			if len(s.Data) < m {
				m = len(s.Data)
			}
		}

		i, data := 0, make([]Float, n)
		for ; i < m; i++ {
			x := Float(0.0)
			for _, s := range series {
				x += s.Data[i]
			}
			data[i] = x
		}

		for ; i < n; i++ {
			data[i] = NaN
		}

		nodeJm.Series = append(nodeJm.Series, Series{
			Hostname:   hostname,
			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
			Data:       data,
		})
	}


/Users/jan/prg/ClusterCockpit/cc-lib/schema/user_test.go
/Users/jan/prg/ClusterCockpit/cc-lib/schema/validate_test.go
/Users/jan/prg/ClusterCockpit/cc-lib/schema/float_test.go


The diagnostics are just the LSP having trouble with multi-file types — those are defined elsewhere in the package. The fix looks correct. Now let me write a unit test.

Let me check the existing types to write a proper test.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-lib.
     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 schema
     7→
     8→import (
     9→	"fmt"
    10→	"io"
    11→	"math"
    12→	"sort"
    13→	"unsafe"
    14→
    15→	"github.com/ClusterCockpit/cc-lib/v2/util"
    16→)
    17→
    18→// JobData maps metric names to their data organized by scope.
    19→// Structure: map[metricName]map[scope]*JobMetric
    20→//
    21→// For example: jobData["cpu_load"][MetricScopeNode] contains node-level CPU load data.
    22→// This structure allows efficient lookup of metrics at different hierarchical levels.
    23→type JobData map[string]map[MetricScope]*JobMetric
    24→
    25→// ScopedJobStats maps metric names to statistical summaries organized by scope.
    26→// Structure: map[metricName]map[scope][]*ScopedStats
    27→//
    28→// Used to store pre-computed statistics without the full time series data,
    29→// reducing memory footprint when only aggregated values are needed.
    30→type ScopedJobStats map[string]map[MetricScope][]*ScopedStats
    31→
    32→// JobMetric contains time series data and statistics for a single metric.
    33→//
    34→// The Series field holds time series data from individual nodes/hardware components,
    35→// while StatisticsSeries provides aggregated statistics across all series over time.
    36→type JobMetric struct {
    37→	StatisticsSeries *StatsSeries `json:"statisticsSeries,omitempty"` // Aggregated statistics over time
    38→	Unit             Unit         `json:"unit"`                       // Unit of measurement
    39→	Series           []Series     `json:"series"`                     // Individual time series data
    40→	Timestep         int          `json:"timestep"`                   // Sampling interval in seconds
    41→}
    42→
    43→// Series represents a single time series of metric measurements.
    44→//
    45→// Each series corresponds to one source (e.g., one node, one core) identified by Hostname and optional ID.
    46→// The Data field contains the time-ordered measurements, and Statistics provides min/avg/max summaries.
    47→type Series struct {
    48→	ID         *string          `json:"id,omitempty"` // Optional ID (e.g., core ID, GPU ID)
    49→	Hostname   string           `json:"hostname"`     // Source hostname
    50→	Data       []Float          `json:"data"`         // Time series measurements
    51→	Statistics MetricStatistics `json:"statistics"`   // Statistical summary (min/avg/max)
    52→}
    53→
    54→// ScopedStats contains statistical summaries for a specific scope (e.g., one node, one socket).
    55→// Used when full time series data isn't needed, only the aggregated statistics.
    56→type ScopedStats struct {
    57→	Hostname string            `json:"hostname"`     // Source hostname
    58→	ID       *string           `json:"id,omitempty"` // Optional scope ID
    59→	Data     *MetricStatistics `json:"data"`         // Statistical summary
    60→}

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

package schema

import (
	"fmt"
	"io"
	"math"
	"sort"
	"unsafe"

	"github.com/ClusterCockpit/cc-lib/v2/util"
)

// JobData maps metric names to their data organized by scope.
// Structure: map[metricName]map[scope]*JobMetric
//
// For example: jobData["cpu_load"][MetricScopeNode] contains node-level CPU load data.
// This structure allows efficient lookup of metrics at different hierarchical levels.
type JobData map[string]map[MetricScope]*JobMetric

// ScopedJobStats maps metric names to statistical summaries organized by scope.
// Structure: map[metricName]map[scope][]*ScopedStats
//
// Used to store pre-computed statistics without the full time series data,
// reducing memory footprint when only aggregated values are needed.
type ScopedJobStats map[string]map[MetricScope][]*ScopedStats

// JobMetric contains time series data and statistics for a single metric.
//
// The Series field holds time series data from individual nodes/hardware components,
// while StatisticsSeries provides aggregated statistics across all series over time.
type JobMetric struct {
	StatisticsSeries *StatsSeries `json:"statisticsSeries,omitempty"` // Aggregated statistics over time
	Unit             Unit         `json:"unit"`                       // Unit of measurement
	Series           []Series     `json:"series"`                     // Individual time series data
	Timestep         int          `json:"timestep"`                   // Sampling interval in seconds
}

// Series represents a single time series of metric measurements.
//
// Each series corresponds to one source (e.g., one node, one core) identified by Hostname and optional ID.
// The Data field contains the time-ordered measurements, and Statistics provides min/avg/max summaries.
type Series struct {
	ID         *string          `json:"id,omitempty"` // Optional ID (e.g., core ID, GPU ID)
	Hostname   string           `json:"hostname"`     // Source hostname
	Data       []Float          `json:"data"`         // Time series measurements
	Statistics MetricStatistics `json:"statistics"`   // Statistical summary (min/avg/max)
}

// ScopedStats contains statistical summaries for a specific scope (e.g., one node, one socket).
// Used when full time series data isn't needed, only the aggregated statistics.
type ScopedStats struct {
	Hostname string            `json:"hostname"`     // Source hostname
	ID       *string           `json:"id,omitempty"` // Optional scope ID
	Data     *MetricStatistics `json:"data"`         // Statistical summary
}


   270→	jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
   271→}
   272→
   273→func (jd *JobData) AddNodeScope(metric string) bool {
   274→	scopes, ok := (*jd)[metric]
   275→	if !ok {
   276→		return false
   277→	}
   278→
   279→	maxScope := MetricScopeInvalid
   280→	for scope := range scopes {
   281→		maxScope = maxScope.Max(scope)
   282→	}
   283→
   284→	if maxScope == MetricScopeInvalid || maxScope == MetricScopeNode {
   285→		return false
   286→	}
   287→
   288→	jm := scopes[maxScope]
   289→	hosts := make(map[string][]Series, 32)
   290→	for _, series := range jm.Series {
   291→		hosts[series.Hostname] = append(hosts[series.Hostname], series)
   292→	}
   293→
   294→	nodeJm := &JobMetric{

	jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
}

func (jd *JobData) AddNodeScope(metric string) bool {
	scopes, ok := (*jd)[metric]
	if !ok {
		return false
	}

	maxScope := MetricScopeInvalid
	for scope := range scopes {
		maxScope = maxScope.Max(scope)
	}

	if maxScope == MetricScopeInvalid || maxScope == MetricScopeNode {
		return false
	}

	jm := scopes[maxScope]
	hosts := make(map[string][]Series, 32)
	for _, series := range jm.Series {
		hosts[series.Hostname] = append(hosts[series.Hostname], series)
	}

	nodeJm := &JobMetric{


Now let me check Float and NaN types, and MetricStatistics.


/Users/jan/prg/ClusterCockpit/cc-lib/schema/float.go:32:type Float float64


No matches found


/Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go:64:type MetricStatistics struct {


/Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go:98:	MetricScopeSocket       MetricScope = "socket"
/Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go:100:	MetricScopeCore         MetricScope = "core"
/Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go:110:	MetricScopeSocket:       5,
/Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics.go:112:	MetricScopeCore:         3,

[Showing results with pagination = limit: 10, offset: 0]


    64→type MetricStatistics struct {
    65→	Avg float64 `json:"avg"` // Average/mean value
    66→	Min float64 `json:"min"` // Minimum value
    67→	Max float64 `json:"max"` // Maximum value
    68→}
    69→
    70→// StatsSeries contains aggregated statistics across multiple time series over time.
    71→//
    72→// Instead of storing individual series, this provides statistical summaries at each time step.
    73→// For example, at time t, Mean[t] is the average value across all series at that time.

type MetricStatistics struct {
	Avg float64 `json:"avg"` // Average/mean value
	Min float64 `json:"min"` // Minimum value
	Max float64 `json:"max"` // Maximum value
}

// StatsSeries contains aggregated statistics across multiple time series over time.
//
// Instead of storing individual series, this provides statistical summaries at each time step.
// For example, at time t, Mean[t] is the average value across all series at that time.


17:// Float is a custom float64 type with special handling for NaN values in JSON and GraphQL serialization.
19:// Standard Go encoding/json treats NaN as an error, but in metric data it's common to have missing
20:// or invalid measurements that should be represented as null in JSON. This type allows NaN values
25://   - NaN values marshal to JSON null
26://   - JSON null unmarshals to NaN
39:	NaN         Float  = Float(math.NaN())
43:func (f Float) IsNaN() bool {
44:	return math.IsNaN(float64(f))
51:// ConvertToFloat converts a regular float64 to a Float, treating -1.0 as a sentinel for NaN.
55:		return NaN
62:// NaN values are serialized as JSON null. Regular float values are
65:	if f.IsNaN() {
73:// JSON null is deserialized as NaN. Numeric strings are parsed as float64
78:		*f = NaN
103:// NaN will be serialized to `null`.
105:	if f.IsNaN() {
114:// encoding/json. NaN data points are serialized as null. Only used via
139:		if s.Data[i].IsNaN() {
150:// NaN values in the Float slice will remain as NaN in the float64 slice.
174:// It efficiently marshals a slice of Float values, converting NaN to null.
183:		if fa[i].IsNaN() {


Now I have all the info needed for the test. Let me write it.


Write

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

package schema

import (
	"testing"
)

func TestAddNodeScope(t *testing.T) {
	// Two hosts with core-level data of different lengths.
	// host1 has 2 cores with 3 data points each.
	// host2 has 2 cores with 5 data points each.
	jd := JobData{
		"flops_any": {
			MetricScopeCore: &JobMetric{
				Unit:     Unit{Base: "F/s"},
				Timestep: 10,
				Series: []Series{
					{Hostname: "host1", Data: []Float{1, 2, 3}, Statistics: MetricStatistics{Min: 1, Avg: 2, Max: 3}},
					{Hostname: "host1", Data: []Float{4, 5, 6}, Statistics: MetricStatistics{Min: 4, Avg: 5, Max: 6}},
					{Hostname: "host2", Data: []Float{10, 20, 30, 40, 50}, Statistics: MetricStatistics{Min: 10, Avg: 30, Max: 50}},
					{Hostname: "host2", Data: []Float{100, 200, 300, 400, 500}, Statistics: MetricStatistics{Min: 100, Avg: 300, Max: 500}},
				},
			},
		},
	}

	ok := jd.AddNodeScope("flops_any")
	if !ok {
		t.Fatal("AddNodeScope returned false")
	}

	nodeMetric, exists := jd["flops_any"][MetricScopeNode]
	if !exists {
		t.Fatal("node scope not created")
	}

	if len(nodeMetric.Series) != 2 {
		t.Fatalf("expected 2 node series, got %d", len(nodeMetric.Series))
	}

	// Build a map for deterministic checking (range over map is random order).
	byHost := make(map[string]Series)
	for _, s := range nodeMetric.Series {
		byHost[s.Hostname] = s
	}

	// host1: sum of cores = [1+4, 2+5, 3+6] = [5, 7, 9], length 3
	h1 := byHost["host1"]
	if len(h1.Data) != 3 {
		t.Fatalf("host1: expected 3 data points, got %d", len(h1.Data))
	}
	expectH1 := []Float{5, 7, 9}
	for i, v := range expectH1 {
		if h1.Data[i] != v {
			t.Errorf("host1 data[%d]: expected %v, got %v", i, v, h1.Data[i])
		}
	}

	// host2: sum of cores = [110, 220, 330, 440, 550], length 5
	h2 := byHost["host2"]
	if len(h2.Data) != 5 {
		t.Fatalf("host2: expected 5 data points, got %d", len(h2.Data))
	}
	expectH2 := []Float{110, 220, 330, 440, 550}
	for i, v := range expectH2 {
		if h2.Data[i] != v {
			t.Errorf("host2 data[%d]: expected %v, got %v", i, v, h2.Data[i])
		}
	}
}

func TestAddNodeScopeUnevenCores(t *testing.T) {
	// Same host, cores with different data lengths.
	jd := JobData{
		"mem_bw": {
			MetricScopeCore: &JobMetric{
				Unit:     Unit{Base: "B/s"},
				Timestep: 10,
				Series: []Series{
					{Hostname: "node1", Data: []Float{1, 2, 3}, Statistics: MetricStatistics{Min: 1, Avg: 2, Max: 3}},
					{Hostname: "node1", Data: []Float{10, 20, 30, 40, 50}, Statistics: MetricStatistics{Min: 10, Avg: 30, Max: 50}},
				},
			},
		},
	}

	ok := jd.AddNodeScope("mem_bw")
	if !ok {
		t.Fatal("AddNodeScope returned false")
	}

	nodeMetric := jd["mem_bw"][MetricScopeNode]
	if len(nodeMetric.Series) != 1 {
		t.Fatalf("expected 1 node series, got %d", len(nodeMetric.Series))
	}

	s := nodeMetric.Series[0]
	// n=5 (max length), m=3 (min length)
	// data[0..2] = sum, data[3..4] = NaN
	if len(s.Data) != 5 {
		t.Fatalf("expected 5 data points, got %d", len(s.Data))
	}

	expect := []Float{11, 22, 33}
	for i, v := range expect {
		if s.Data[i] != v {
			t.Errorf("data[%d]: expected %v, got %v", i, v, s.Data[i])
		}
	}

	// Remaining should be NaN
	for i := 3; i < 5; i++ {
		if !s.Data[i].IsNaN() {
			t.Errorf("data[%d]: expected NaN, got %v", i, s.Data[i])
		}
	}
}


File created successfully at: /Users/jan/prg/ClusterCockpit/cc-lib/schema/metrics_test.go

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

package schema

import (
	"testing"
)

func TestAddNodeScope(t *testing.T) {
	// Two hosts with core-level data of different lengths.
	// host1 has 2 cores with 3 data points each.
	// host2 has 2 cores with 5 data points each.
	jd := JobData{
		"flops_any": {
			MetricScopeCore: &JobMetric{
				Unit:     Unit{Base: "F/s"},
				Timestep: 10,
				Series: []Series{
					{Hostname: "host1", Data: []Float{1, 2, 3}, Statistics: MetricStatistics{Min: 1, Avg: 2, Max: 3}},
					{Hostname: "host1", Data: []Float{4, 5, 6}, Statistics: MetricStatistics{Min: 4, Avg: 5, Max: 6}},
					{Hostname: "host2", Data: []Float{10, 20, 30, 40, 50}, Statistics: MetricStatistics{Min: 10, Avg: 30, Max: 50}},
					{Hostname: "host2", Data: []Float{100, 200, 300, 400, 500}, Statistics: MetricStatistics{Min: 100, Avg: 300, Max: 500}},
				},
			},
		},
	}

	ok := jd.AddNodeScope("flops_any")
	if !ok {
		t.Fatal("AddNodeScope returned false")
	}

	nodeMetric, exists := jd["flops_any"][MetricScopeNode]
	if !exists {
		t.Fatal("node scope not created")
	}

	if len(nodeMetric.Series) != 2 {
		t.Fatalf("expected 2 node series, got %d", len(nodeMetric.Series))
	}

	// Build a map for deterministic checking (range over map is random order).
	byHost := make(map[string]Series)
	for _, s := range nodeMetric.Series {
		byHost[s.Hostname] = s
	}

	// host1: sum of cores = [1+4, 2+5, 3+6] = [5, 7, 9], length 3
	h1 := byHost["host1"]
	if len(h1.Data) != 3 {
		t.Fatalf("host1: expected 3 data points, got %d", len(h1.Data))
	}
	expectH1 := []Float{5, 7, 9}
	for i, v := range expectH1 {
		if h1.Data[i] != v {
			t.Errorf("host1 data[%d]: expected %v, got %v", i, v, h1.Data[i])
		}
	}

	// host2: sum of cores = [110, 220, 330, 440, 550], length 5
	h2 := byHost["host2"]
	if len(h2.Data) != 5 {
		t.Fatalf("host2: expected 5 data points, got %d", len(h2.Data))
	}
	expectH2 := []Float{110, 220, 330, 440, 550}
	for i, v := range expectH2 {
		if h2.Data[i] != v {
			t.Errorf("host2 data[%d]: expected %v, got %v", i, v, h2.Data[i])
		}
	}
}

func TestAddNodeScopeUnevenCores(t *testing.T) {
	// Same host, cores with different data lengths.
	jd := JobData{
		"mem_bw": {
			MetricScopeCore: &JobMetric{
				Unit:     Unit{Base: "B/s"},
				Timestep: 10,
				Series: []Series{
					{Hostname: "node1", Data: []Float{1, 2, 3}, Statistics: MetricStatistics{Min: 1, Avg: 2, Max: 3}},
					{Hostname: "node1", Data: []Float{10, 20, 30, 40, 50}, Statistics: MetricStatistics{Min: 10, Avg: 30, Max: 50}},
				},
			},
		},
	}

	ok := jd.AddNodeScope("mem_bw")
	if !ok {
		t.Fatal("AddNodeScope returned false")
	}

	nodeMetric := jd["mem_bw"][MetricScopeNode]
	if len(nodeMetric.Series) != 1 {
		t.Fatalf("expected 1 node series, got %d", len(nodeMetric.Series))
	}

	s := nodeMetric.Series[0]
	// n=5 (max length), m=3 (min length)
	// data[0..2] = sum, data[3..4] = NaN
	if len(s.Data) != 5 {
		t.Fatalf("expected 5 data points, got %d", len(s.Data))
	}

	expect := []Float{11, 22, 33}
	for i, v := range expect {
		if s.Data[i] != v {
			t.Errorf("data[%d]: expected %v, got %v", i, v, s.Data[i])
		}
	}

	// Remaining should be NaN
	for i := 3; i < 5; i++ {
		if !s.Data[i].IsNaN() {
			t.Errorf("data[%d]: expected NaN, got %v", i, s.Data[i])
		}
	}
}


Bash

cd /Users/jan/prg/ClusterCockpit/cc-lib && go test ./schema/ -run "TestAddNodeScope" -v 2>&1

Run the new unit tests


=== RUN   TestAddNodeScope
--- PASS: TestAddNodeScope (0.00s)
=== RUN   TestAddNodeScopeUnevenCores
--- PASS: TestAddNodeScopeUnevenCores (0.00s)
PASS
ok  	github.com/ClusterCockpit/cc-lib/v2/schema	0.528s
Shell cwd was reset to /Users/jan/prg/ClusterCockpit/cc-backend


Both tests pass. Here's a summary of what was done:

**Fix applied in cc-lib** (`schema/metrics.go:307-324`):

1. **`n`/`m` now computed from local `series`** (current hostname's sub-node series) instead of `jm.Series` (all hostnames) — fixes the crash where `data` was undersized
2. **`data` sized to `n`** (max length for this host) instead of `len(series[0].Data)` — prevents index-out-of-range panic
3. **Summation iterates local `series`** instead of `jm.Series` — fixes the logic bug where each node got the sum of ALL hosts' cores

**Tests added** (`schema/metrics_test.go`):
- `TestAddNodeScope` — two hosts with different data lengths (3 vs 5 points), verifies correct per-host aggregation and no panic
- `TestAddNodeScopeUnevenCores` — single host with cores of different lengths, verifies NaN padding for shorter series

You'll need to commit and release this in cc-lib, then bump the dependency in cc-backend's `go.mod`.


Analyse if similar errors exist in cc-lib metrics.go


     1→# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)
     2→
     3→## Context
     4→
     5→The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).
     6→
     7→## Root Cause (in cc-lib)
     8→
     9→`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:
    10→
    11→### Bug 1 — The crash (line 327)
    12→- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)
    13→- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname
    14→- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data
    15→
    16→### Bug 2 — Logic error (lines 318-323)
    17→- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)
    18→- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics
    19→
    20→## Fix (in cc-lib `schema/metrics.go`)
    21→
    22→Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):
    23→
    24→```go
    25→// Before (BUGGY):
    26→n, m := 0, len(jm.Series[0].Data)
    27→for _, series := range jm.Series {
    28→    ...
    29→}
    30→i, data := 0, make([]Float, len(series[0].Data))
    31→for ; i < m; i++ {
    32→    x := Float(0.0)
    33→    for _, series := range jm.Series {
    34→        x += series.Data[i]
    35→    }
    36→    data[i] = x
    37→}
    38→
    39→// After (FIXED):
    40→n, m := 0, len(series[0].Data)
    41→for _, s := range series {
    42→    if len(s.Data) > n {
    43→        n = len(s.Data)
    44→    }
    45→    if len(s.Data) < m {
    46→        m = len(s.Data)
    47→    }
    48→}
    49→i, data := 0, make([]Float, n)
    50→for ; i < m; i++ {
    51→    x := Float(0.0)
    52→    for _, s := range series {
    53→        x += s.Data[i]
    54→    }
    55→    data[i] = x
    56→}
    57→```
    58→
    59→Key changes:
    60→1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`
    61→2. `data` sized to `n` (max length for this host), not `series[0].Data` length
    62→3. Summation iterates local `series`, not `jm.Series`
    63→
    64→## Where to Apply
    65→
    66→The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:
    67→
    68→1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend
    69→2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug
    70→
    71→**Recommended: Option 1** — fix cc-lib.
    72→
    73→## Verification
    74→
    75→- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation
    76→- Integration: archive a job with mixed-length metric data
    77→

# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)

## Context

The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).

## Root Cause (in cc-lib)

`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:

### Bug 1 — The crash (line 327)
- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)
- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname
- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data

### Bug 2 — Logic error (lines 318-323)
- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)
- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics

## Fix (in cc-lib `schema/metrics.go`)

Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):

```go
// Before (BUGGY):
n, m := 0, len(jm.Series[0].Data)
for _, series := range jm.Series {
    ...
}
i, data := 0, make([]Float, len(series[0].Data))
for ; i < m; i++ {
    x := Float(0.0)
    for _, series := range jm.Series {
        x += series.Data[i]
    }
    data[i] = x
}

// After (FIXED):
n, m := 0, len(series[0].Data)
for _, s := range series {
    if len(s.Data) > n {
        n = len(s.Data)
    }
    if len(s.Data) < m {
        m = len(s.Data)
    }
}
i, data := 0, make([]Float, n)
for ; i < m; i++ {
    x := Float(0.0)
    for _, s := range series {
        x += s.Data[i]
    }
    data[i] = x
}
```

Key changes:
1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`
2. `data` sized to `n` (max length for this host), not `series[0].Data` length
3. Summation iterates local `series`, not `jm.Series`

## Where to Apply

The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:

1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend
2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug

**Recommended: Option 1** — fix cc-lib.

## Verification

- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation
- Integration: archive a job with mixed-length metric data


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-lib.
     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 schema
     7→
     8→import (
     9→	"fmt"
    10→	"io"
    11→	"math"
    12→	"sort"
    13→	"unsafe"
    14→
    15→	"github.com/ClusterCockpit/cc-lib/v2/util"
    16→)
    17→
    18→// JobData maps metric names to their data organized by scope.
    19→// Structure: map[metricName]map[scope]*JobMetric
    20→//
    21→// For example: jobData["cpu_load"][MetricScopeNode] contains node-level CPU load data.
    22→// This structure allows efficient lookup of metrics at different hierarchical levels.
    23→type JobData map[string]map[MetricScope]*JobMetric
    24→
    25→// ScopedJobStats maps metric names to statistical summaries organized by scope.
    26→// Structure: map[metricName]map[scope][]*ScopedStats
    27→//
    28→// Used to store pre-computed statistics without the full time series data,
    29→// reducing memory footprint when only aggregated values are needed.
    30→type ScopedJobStats map[string]map[MetricScope][]*ScopedStats
    31→
    32→// JobMetric contains time series data and statistics for a single metric.
    33→//
    34→// The Series field holds time series data from individual nodes/hardware components,
    35→// while StatisticsSeries provides aggregated statistics across all series over time.
    36→type JobMetric struct {
    37→	StatisticsSeries *StatsSeries `json:"statisticsSeries,omitempty"` // Aggregated statistics over time
    38→	Unit             Unit         `json:"unit"`                       // Unit of measurement
    39→	Series           []Series     `json:"series"`                     // Individual time series data
    40→	Timestep         int          `json:"timestep"`                   // Sampling interval in seconds
    41→}
    42→
    43→// Series represents a single time series of metric measurements.
    44→//
    45→// Each series corresponds to one source (e.g., one node, one core) identified by Hostname and optional ID.
    46→// The Data field contains the time-ordered measurements, and Statistics provides min/avg/max summaries.
    47→type Series struct {
    48→	ID         *string          `json:"id,omitempty"` // Optional ID (e.g., core ID, GPU ID)
    49→	Hostname   string           `json:"hostname"`     // Source hostname
    50→	Data       []Float          `json:"data"`         // Time series measurements
    51→	Statistics MetricStatistics `json:"statistics"`   // Statistical summary (min/avg/max)
    52→}
    53→
    54→// ScopedStats contains statistical summaries for a specific scope (e.g., one node, one socket).
    55→// Used when full time series data isn't needed, only the aggregated statistics.
    56→type ScopedStats struct {
    57→	Hostname string            `json:"hostname"`     // Source hostname
    58→	ID       *string           `json:"id,omitempty"` // Optional scope ID
    59→	Data     *MetricStatistics `json:"data"`         // Statistical summary
    60→}
    61→
    62→// MetricStatistics holds statistical summary values for metric data.
    63→// Provides the common statistical aggregations used throughout ClusterCockpit.
    64→type MetricStatistics struct {
    65→	Avg float64 `json:"avg"` // Average/mean value
    66→	Min float64 `json:"min"` // Minimum value
    67→	Max float64 `json:"max"` // Maximum value
    68→}
    69→
    70→// StatsSeries contains aggregated statistics across multiple time series over time.
    71→//
    72→// Instead of storing individual series, this provides statistical summaries at each time step.
    73→// For example, at time t, Mean[t] is the average value across all series at that time.
    74→// Percentiles provides specified percentile values at each time step.
    75→type StatsSeries struct {
    76→	Percentiles map[int][]Float `json:"percentiles,omitempty"` // Percentile values over time (e.g., 10th, 50th, 90th)
    77→	Mean        []Float         `json:"mean"`                  // Mean values over time
    78→	Median      []Float         `json:"median"`                // Median values over time
    79→	Min         []Float         `json:"min"`                   // Minimum values over time
    80→	Max         []Float         `json:"max"`                   // Maximum values over time
    81→}
    82→
    83→// MetricScope defines the hierarchical level at which a metric is measured.
    84→//
    85→// Scopes form a hierarchy from coarse-grained (node) to fine-grained (hwthread/accelerator):
    86→//
    87→//	node > socket > memoryDomain > core > hwthread
    88→//	accelerator is a special scope at the same level as hwthread
    89→//
    90→// The scopePrecedence map defines numeric ordering for scope comparisons,
    91→// which is used when aggregating metrics across different scopes.
    92→type MetricScope string
    93→
    94→const (
    95→	MetricScopeInvalid MetricScope = "invalid_scope"
    96→
    97→	MetricScopeNode         MetricScope = "node"
    98→	MetricScopeSocket       MetricScope = "socket"
    99→	MetricScopeMemoryDomain MetricScope = "memoryDomain"
   100→	MetricScopeCore         MetricScope = "core"
   101→	MetricScopeHWThread     MetricScope = "hwthread"
   102→
   103→	// TODO: Add Job and Application scopes
   104→
   105→	MetricScopeAccelerator MetricScope = "accelerator"
   106→)
   107→
   108→var metricScopeGranularity map[MetricScope]int = map[MetricScope]int{
   109→	MetricScopeNode:         10,
   110→	MetricScopeSocket:       5,
   111→	MetricScopeMemoryDomain: 4,
   112→	MetricScopeCore:         3,
   113→	MetricScopeHWThread:     2,
   114→	/* Special-Case Accelerator
   115→	 * -> No conversion possible if native scope is HWTHREAD
   116→	 * -> Therefore needs to be less than HWTREAD, else max() would return unhandled case
   117→	 * -> If nativeScope is accelerator, accelerator metrics return correctly
   118→	 */
   119→	MetricScopeAccelerator: 1,
   120→
   121→	MetricScopeInvalid: -1,
   122→}
   123→
   124→func (e *MetricScope) LT(other MetricScope) bool {
   125→	a := metricScopeGranularity[*e]
   126→	b := metricScopeGranularity[other]
   127→	return a < b
   128→}
   129→
   130→func (e *MetricScope) LTE(other MetricScope) bool {
   131→	a := metricScopeGranularity[*e]
   132→	b := metricScopeGranularity[other]
   133→	return a <= b
   134→}
   135→
   136→func (e *MetricScope) Max(other MetricScope) MetricScope {
   137→	a := metricScopeGranularity[*e]
   138→	b := metricScopeGranularity[other]
   139→	if a > b {
   140→		return *e
   141→	}
   142→	return other
   143→}
   144→
   145→func (e *MetricScope) UnmarshalGQL(v any) error {
   146→	str, ok := v.(string)
   147→	if !ok {
   148→		return fmt.Errorf("SCHEMA/METRICS > enums must be strings")
   149→	}
   150→
   151→	*e = MetricScope(str)
   152→	if !e.Valid() {
   153→		return fmt.Errorf("SCHEMA/METRICS > %s is not a valid MetricScope", str)
   154→	}
   155→	return nil
   156→}
   157→
   158→func (e MetricScope) MarshalGQL(w io.Writer) {
   159→	fmt.Fprintf(w, "\"%s\"", e)
   160→}
   161→
   162→func (e MetricScope) Valid() bool {
   163→	gran, ok := metricScopeGranularity[e]
   164→	return ok && gran > 0
   165→}
   166→
   167→func (jd *JobData) Size() int {
   168→	n := 128
   169→	for _, scopes := range *jd {
   170→		for _, metric := range scopes {
   171→			if metric.StatisticsSeries != nil {
   172→				n += len(metric.StatisticsSeries.Max)
   173→				n += len(metric.StatisticsSeries.Mean)
   174→				n += len(metric.StatisticsSeries.Median)
   175→				n += len(metric.StatisticsSeries.Min)
   176→			}
   177→
   178→			for _, series := range metric.Series {
   179→				n += len(series.Data)
   180→			}
   181→		}
   182→	}
   183→	return n * int(unsafe.Sizeof(Float(0)))
   184→}
   185→
   186→const smooth bool = false
   187→
   188→func (jm *JobMetric) AddStatisticsSeries() {
   189→	if jm.StatisticsSeries != nil || len(jm.Series) < 4 {
   190→		return
   191→	}
   192→
   193→	n, m := 0, len(jm.Series[0].Data)
   194→	for _, series := range jm.Series {
   195→		if len(series.Data) > n {
   196→			n = len(series.Data)
   197→		}
   198→		if len(series.Data) < m {
   199→			m = len(series.Data)
   200→		}
   201→	}
   202→
   203→	// mean := make([]Float, n)
   204→	min, median, max := make([]Float, n), make([]Float, n), make([]Float, n)
   205→	i := 0
   206→	for ; i < m; i++ {
   207→		seriesCount := len(jm.Series)
   208→		// ssum := 0.0
   209→		smin, smed, smax := math.MaxFloat32, make([]float64, seriesCount), -math.MaxFloat32
   210→		notnan := 0
   211→		for j := range seriesCount {
   212→			x := float64(jm.Series[j].Data[i])
   213→			if math.IsNaN(x) {
   214→				continue
   215→			}
   216→
   217→			notnan += 1
   218→			// ssum += x
   219→			smed[j] = x
   220→			smin = math.Min(smin, x)
   221→			smax = math.Max(smax, x)
   222→		}
   223→
   224→		if notnan < 3 {
   225→			min[i] = NaN
   226→			// mean[i] = NaN
   227→			median[i] = NaN
   228→			max[i] = NaN
   229→		} else {
   230→			min[i] = Float(smin)
   231→			// mean[i] = Float(ssum / float64(notnan))
   232→			max[i] = Float(smax)
   233→
   234→			medianRaw, err := util.Median(smed)
   235→			if err != nil {
   236→				median[i] = NaN
   237→			} else {
   238→				median[i] = Float(medianRaw)
   239→			}
   240→		}
   241→	}
   242→
   243→	for ; i < n; i++ {
   244→		min[i] = NaN
   245→		// mean[i] = NaN
   246→		median[i] = NaN
   247→		max[i] = NaN
   248→	}
   249→
   250→	if smooth {
   251→		for i := 2; i < len(median)-2; i++ {
   252→			if min[i].IsNaN() {
   253→				continue
   254→			}
   255→
   256→			min[i] = (min[i-2] + min[i-1] + min[i] + min[i+1] + min[i+2]) / 5
   257→			max[i] = (max[i-2] + max[i-1] + max[i] + max[i+1] + max[i+2]) / 5
   258→			// mean[i] = (mean[i-2] + mean[i-1] + mean[i] + mean[i+1] + mean[i+2]) / 5
   259→			// Reduce Median further
   260→			smoothRaw := []float64{float64(median[i-2]), float64(median[i-1]), float64(median[i]), float64(median[i+1]), float64(median[i+2])}
   261→			smoothMedian, err := util.Median(smoothRaw)
   262→			if err != nil {
   263→				median[i] = NaN
   264→			} else {
   265→				median[i] = Float(smoothMedian)
   266→			}
   267→		}
   268→	}
   269→
   270→	jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
   271→}
   272→
   273→func (jd *JobData) AddNodeScope(metric string) bool {
   274→	scopes, ok := (*jd)[metric]
   275→	if !ok {
   276→		return false
   277→	}
   278→
   279→	maxScope := MetricScopeInvalid
   280→	for scope := range scopes {
   281→		maxScope = maxScope.Max(scope)
   282→	}
   283→
   284→	if maxScope == MetricScopeInvalid || maxScope == MetricScopeNode {
   285→		return false
   286→	}
   287→
   288→	jm := scopes[maxScope]
   289→	hosts := make(map[string][]Series, 32)
   290→	for _, series := range jm.Series {
   291→		hosts[series.Hostname] = append(hosts[series.Hostname], series)
   292→	}
   293→
   294→	nodeJm := &JobMetric{
   295→		Unit:     jm.Unit,
   296→		Timestep: jm.Timestep,
   297→		Series:   make([]Series, 0, len(hosts)),
   298→	}
   299→	for hostname, series := range hosts {
   300→		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
   301→		for _, series := range series {
   302→			sum += series.Statistics.Avg
   303→			min = math.Min(min, series.Statistics.Min)
   304→			max = math.Max(max, series.Statistics.Max)
   305→		}
   306→
   307→		n, m := 0, len(series[0].Data)
   308→		for _, s := range series {
   309→			if len(s.Data) > n {
   310→				n = len(s.Data)
   311→			}
   312→			if len(s.Data) < m {
   313→				m = len(s.Data)
   314→			}
   315→		}
   316→
   317→		i, data := 0, make([]Float, n)
   318→		for ; i < m; i++ {
   319→			x := Float(0.0)
   320→			for _, s := range series {
   321→				x += s.Data[i]
   322→			}
   323→			data[i] = x
   324→		}
   325→
   326→		for ; i < n; i++ {
   327→			data[i] = NaN
   328→		}
   329→
   330→		nodeJm.Series = append(nodeJm.Series, Series{
   331→			Hostname:   hostname,
   332→			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
   333→			Data:       data,
   334→		})
   335→	}
   336→
   337→	scopes[MetricScopeNode] = nodeJm
   338→	return true
   339→}
   340→
   341→func (jd *JobData) RoundMetricStats() {
   342→	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
   343→	for _, scopes := range *jd {
   344→		for _, jm := range scopes {
   345→			for index := range jm.Series {
   346→				jm.Series[index].Statistics = MetricStatistics{
   347→					Avg: (math.Round(jm.Series[index].Statistics.Avg*100) / 100),
   348→					Min: (math.Round(jm.Series[index].Statistics.Min*100) / 100),
   349→					Max: (math.Round(jm.Series[index].Statistics.Max*100) / 100),
   350→				}
   351→			}
   352→		}
   353→	}
   354→}
   355→
   356→func (sjs *ScopedJobStats) RoundScopedMetricStats() {
   357→	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
   358→	for _, scopes := range *sjs {
   359→		for _, stats := range scopes {
   360→			for index := range stats {
   361→				roundedStats := MetricStatistics{
   362→					Avg: (math.Round(stats[index].Data.Avg*100) / 100),
   363→					Min: (math.Round(stats[index].Data.Min*100) / 100),
   364→					Max: (math.Round(stats[index].Data.Max*100) / 100),
   365→				}
   366→				stats[index].Data = &roundedStats
   367→			}
   368→		}
   369→	}
   370→}
   371→
   372→func (jm *JobMetric) AddPercentiles(ps []int) bool {
   373→	if jm.StatisticsSeries == nil {
   374→		jm.AddStatisticsSeries()
   375→	}
   376→
   377→	if len(jm.Series) < 3 {
   378→		return false
   379→	}
   380→
   381→	if jm.StatisticsSeries.Percentiles == nil {
   382→		jm.StatisticsSeries.Percentiles = make(map[int][]Float, len(ps))
   383→	}
   384→
   385→	n := 0
   386→	for _, series := range jm.Series {
   387→		if len(series.Data) > n {
   388→			n = len(series.Data)
   389→		}
   390→	}
   391→
   392→	data := make([][]float64, n)
   393→	for i := range n {
   394→		vals := make([]float64, 0, len(jm.Series))
   395→		for _, series := range jm.Series {
   396→			if i < len(series.Data) {
   397→				vals = append(vals, float64(series.Data[i]))
   398→			}
   399→		}
   400→
   401→		sort.Float64s(vals)
   402→		data[i] = vals
   403→	}
   404→
   405→	for _, p := range ps {
   406→		if p < 1 || p > 99 {
   407→			panic("SCHEMA/METRICS > invalid percentile")
   408→		}
   409→
   410→		if _, ok := jm.StatisticsSeries.Percentiles[p]; ok {
   411→			continue
   412→		}
   413→
   414→		percentiles := make([]Float, n)
   415→		for i := range n {
   416→			sorted := data[i]
   417→			percentiles[i] = Float(sorted[(len(sorted)*p)/100])
   418→		}
   419→
   420→		jm.StatisticsSeries.Percentiles[p] = percentiles
   421→	}
   422→
   423→	return true
   424→}
   425→

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

package schema

import (
	"fmt"
	"io"
	"math"
	"sort"
	"unsafe"

	"github.com/ClusterCockpit/cc-lib/v2/util"
)

// JobData maps metric names to their data organized by scope.
// Structure: map[metricName]map[scope]*JobMetric
//
// For example: jobData["cpu_load"][MetricScopeNode] contains node-level CPU load data.
// This structure allows efficient lookup of metrics at different hierarchical levels.
type JobData map[string]map[MetricScope]*JobMetric

// ScopedJobStats maps metric names to statistical summaries organized by scope.
// Structure: map[metricName]map[scope][]*ScopedStats
//
// Used to store pre-computed statistics without the full time series data,
// reducing memory footprint when only aggregated values are needed.
type ScopedJobStats map[string]map[MetricScope][]*ScopedStats

// JobMetric contains time series data and statistics for a single metric.
//
// The Series field holds time series data from individual nodes/hardware components,
// while StatisticsSeries provides aggregated statistics across all series over time.
type JobMetric struct {
	StatisticsSeries *StatsSeries `json:"statisticsSeries,omitempty"` // Aggregated statistics over time
	Unit             Unit         `json:"unit"`                       // Unit of measurement
	Series           []Series     `json:"series"`                     // Individual time series data
	Timestep         int          `json:"timestep"`                   // Sampling interval in seconds
}

// Series represents a single time series of metric measurements.
//
// Each series corresponds to one source (e.g., one node, one core) identified by Hostname and optional ID.
// The Data field contains the time-ordered measurements, and Statistics provides min/avg/max summaries.
type Series struct {
	ID         *string          `json:"id,omitempty"` // Optional ID (e.g., core ID, GPU ID)
	Hostname   string           `json:"hostname"`     // Source hostname
	Data       []Float          `json:"data"`         // Time series measurements
	Statistics MetricStatistics `json:"statistics"`   // Statistical summary (min/avg/max)
}

// ScopedStats contains statistical summaries for a specific scope (e.g., one node, one socket).
// Used when full time series data isn't needed, only the aggregated statistics.
type ScopedStats struct {
	Hostname string            `json:"hostname"`     // Source hostname
	ID       *string           `json:"id,omitempty"` // Optional scope ID
	Data     *MetricStatistics `json:"data"`         // Statistical summary
}

// MetricStatistics holds statistical summary values for metric data.
// Provides the common statistical aggregations used throughout ClusterCockpit.
type MetricStatistics struct {
	Avg float64 `json:"avg"` // Average/mean value
	Min float64 `json:"min"` // Minimum value
	Max float64 `json:"max"` // Maximum value
}

// StatsSeries contains aggregated statistics across multiple time series over time.
//
// Instead of storing individual series, this provides statistical summaries at each time step.
// For example, at time t, Mean[t] is the average value across all series at that time.
// Percentiles provides specified percentile values at each time step.
type StatsSeries struct {
	Percentiles map[int][]Float `json:"percentiles,omitempty"` // Percentile values over time (e.g., 10th, 50th, 90th)
	Mean        []Float         `json:"mean"`                  // Mean values over time
	Median      []Float         `json:"median"`                // Median values over time
	Min         []Float         `json:"min"`                   // Minimum values over time
	Max         []Float         `json:"max"`                   // Maximum values over time
}

// MetricScope defines the hierarchical level at which a metric is measured.
//
// Scopes form a hierarchy from coarse-grained (node) to fine-grained (hwthread/accelerator):
//
//	node > socket > memoryDomain > core > hwthread
//	accelerator is a special scope at the same level as hwthread
//
// The scopePrecedence map defines numeric ordering for scope comparisons,
// which is used when aggregating metrics across different scopes.
type MetricScope string

const (
	MetricScopeInvalid MetricScope = "invalid_scope"

	MetricScopeNode         MetricScope = "node"
	MetricScopeSocket       MetricScope = "socket"
	MetricScopeMemoryDomain MetricScope = "memoryDomain"
	MetricScopeCore         MetricScope = "core"
	MetricScopeHWThread     MetricScope = "hwthread"

	// TODO: Add Job and Application scopes

	MetricScopeAccelerator MetricScope = "accelerator"
)

var metricScopeGranularity map[MetricScope]int = map[MetricScope]int{
	MetricScopeNode:         10,
	MetricScopeSocket:       5,
	MetricScopeMemoryDomain: 4,
	MetricScopeCore:         3,
	MetricScopeHWThread:     2,
	/* Special-Case Accelerator
	 * -> No conversion possible if native scope is HWTHREAD
	 * -> Therefore needs to be less than HWTREAD, else max() would return unhandled case
	 * -> If nativeScope is accelerator, accelerator metrics return correctly
	 */
	MetricScopeAccelerator: 1,

	MetricScopeInvalid: -1,
}

func (e *MetricScope) LT(other MetricScope) bool {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	return a < b
}

func (e *MetricScope) LTE(other MetricScope) bool {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	return a <= b
}

func (e *MetricScope) Max(other MetricScope) MetricScope {
	a := metricScopeGranularity[*e]
	b := metricScopeGranularity[other]
	if a > b {
		return *e
	}
	return other
}

func (e *MetricScope) UnmarshalGQL(v any) error {
	str, ok := v.(string)
	if !ok {
		return fmt.Errorf("SCHEMA/METRICS > enums must be strings")
	}

	*e = MetricScope(str)
	if !e.Valid() {
		return fmt.Errorf("SCHEMA/METRICS > %s is not a valid MetricScope", str)
	}
	return nil
}

func (e MetricScope) MarshalGQL(w io.Writer) {
	fmt.Fprintf(w, "\"%s\"", e)
}

func (e MetricScope) Valid() bool {
	gran, ok := metricScopeGranularity[e]
	return ok && gran > 0
}

func (jd *JobData) Size() int {
	n := 128
	for _, scopes := range *jd {
		for _, metric := range scopes {
			if metric.StatisticsSeries != nil {
				n += len(metric.StatisticsSeries.Max)
				n += len(metric.StatisticsSeries.Mean)
				n += len(metric.StatisticsSeries.Median)
				n += len(metric.StatisticsSeries.Min)
			}

			for _, series := range metric.Series {
				n += len(series.Data)
			}
		}
	}
	return n * int(unsafe.Sizeof(Float(0)))
}

const smooth bool = false

func (jm *JobMetric) AddStatisticsSeries() {
	if jm.StatisticsSeries != nil || len(jm.Series) < 4 {
		return
	}

	n, m := 0, len(jm.Series[0].Data)
	for _, series := range jm.Series {
		if len(series.Data) > n {
			n = len(series.Data)
		}
		if len(series.Data) < m {
			m = len(series.Data)
		}
	}

	// mean := make([]Float, n)
	min, median, max := make([]Float, n), make([]Float, n), make([]Float, n)
	i := 0
	for ; i < m; i++ {
		seriesCount := len(jm.Series)
		// ssum := 0.0
		smin, smed, smax := math.MaxFloat32, make([]float64, seriesCount), -math.MaxFloat32
		notnan := 0
		for j := range seriesCount {
			x := float64(jm.Series[j].Data[i])
			if math.IsNaN(x) {
				continue
			}

			notnan += 1
			// ssum += x
			smed[j] = x
			smin = math.Min(smin, x)
			smax = math.Max(smax, x)
		}

		if notnan < 3 {
			min[i] = NaN
			// mean[i] = NaN
			median[i] = NaN
			max[i] = NaN
		} else {
			min[i] = Float(smin)
			// mean[i] = Float(ssum / float64(notnan))
			max[i] = Float(smax)

			medianRaw, err := util.Median(smed)
			if err != nil {
				median[i] = NaN
			} else {
				median[i] = Float(medianRaw)
			}
		}
	}

	for ; i < n; i++ {
		min[i] = NaN
		// mean[i] = NaN
		median[i] = NaN
		max[i] = NaN
	}

	if smooth {
		for i := 2; i < len(median)-2; i++ {
			if min[i].IsNaN() {
				continue
			}

			min[i] = (min[i-2] + min[i-1] + min[i] + min[i+1] + min[i+2]) / 5
			max[i] = (max[i-2] + max[i-1] + max[i] + max[i+1] + max[i+2]) / 5
			// mean[i] = (mean[i-2] + mean[i-1] + mean[i] + mean[i+1] + mean[i+2]) / 5
			// Reduce Median further
			smoothRaw := []float64{float64(median[i-2]), float64(median[i-1]), float64(median[i]), float64(median[i+1]), float64(median[i+2])}
			smoothMedian, err := util.Median(smoothRaw)
			if err != nil {
				median[i] = NaN
			} else {
				median[i] = Float(smoothMedian)
			}
		}
	}

	jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
}

func (jd *JobData) AddNodeScope(metric string) bool {
	scopes, ok := (*jd)[metric]
	if !ok {
		return false
	}

	maxScope := MetricScopeInvalid
	for scope := range scopes {
		maxScope = maxScope.Max(scope)
	}

	if maxScope == MetricScopeInvalid || maxScope == MetricScopeNode {
		return false
	}

	jm := scopes[maxScope]
	hosts := make(map[string][]Series, 32)
	for _, series := range jm.Series {
		hosts[series.Hostname] = append(hosts[series.Hostname], series)
	}

	nodeJm := &JobMetric{
		Unit:     jm.Unit,
		Timestep: jm.Timestep,
		Series:   make([]Series, 0, len(hosts)),
	}
	for hostname, series := range hosts {
		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
		for _, series := range series {
			sum += series.Statistics.Avg
			min = math.Min(min, series.Statistics.Min)
			max = math.Max(max, series.Statistics.Max)
		}

		n, m := 0, len(series[0].Data)
		for _, s := range series {
			if len(s.Data) > n {
				n = len(s.Data)
			}
			if len(s.Data) < m {
				m = len(s.Data)
			}
		}

		i, data := 0, make([]Float, n)
		for ; i < m; i++ {
			x := Float(0.0)
			for _, s := range series {
				x += s.Data[i]
			}
			data[i] = x
		}

		for ; i < n; i++ {
			data[i] = NaN
		}

		nodeJm.Series = append(nodeJm.Series, Series{
			Hostname:   hostname,
			Statistics: MetricStatistics{Min: min, Avg: sum / float64(len(series)), Max: max},
			Data:       data,
		})
	}

	scopes[MetricScopeNode] = nodeJm
	return true
}

func (jd *JobData) RoundMetricStats() {
	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
	for _, scopes := range *jd {
		for _, jm := range scopes {
			for index := range jm.Series {
				jm.Series[index].Statistics = MetricStatistics{
					Avg: (math.Round(jm.Series[index].Statistics.Avg*100) / 100),
					Min: (math.Round(jm.Series[index].Statistics.Min*100) / 100),
					Max: (math.Round(jm.Series[index].Statistics.Max*100) / 100),
				}
			}
		}
	}
}

func (sjs *ScopedJobStats) RoundScopedMetricStats() {
	// TODO: Make Digit-Precision Configurable? (Currently: Fixed to 2 Digits)
	for _, scopes := range *sjs {
		for _, stats := range scopes {
			for index := range stats {
				roundedStats := MetricStatistics{
					Avg: (math.Round(stats[index].Data.Avg*100) / 100),
					Min: (math.Round(stats[index].Data.Min*100) / 100),
					Max: (math.Round(stats[index].Data.Max*100) / 100),
				}
				stats[index].Data = &roundedStats
			}
		}
	}
}

func (jm *JobMetric) AddPercentiles(ps []int) bool {
	if jm.StatisticsSeries == nil {
		jm.AddStatisticsSeries()
	}

	if len(jm.Series) < 3 {
		return false
	}

	if jm.StatisticsSeries.Percentiles == nil {
		jm.StatisticsSeries.Percentiles = make(map[int][]Float, len(ps))
	}

	n := 0
	for _, series := range jm.Series {
		if len(series.Data) > n {
			n = len(series.Data)
		}
	}

	data := make([][]float64, n)
	for i := range n {
		vals := make([]float64, 0, len(jm.Series))
		for _, series := range jm.Series {
			if i < len(series.Data) {
				vals = append(vals, float64(series.Data[i]))
			}
		}

		sort.Float64s(vals)
		data[i] = vals
	}

	for _, p := range ps {
		if p < 1 || p > 99 {
			panic("SCHEMA/METRICS > invalid percentile")
		}

		if _, ok := jm.StatisticsSeries.Percentiles[p]; ok {
			continue
		}

		percentiles := make([]Float, n)
		for i := range n {
			sorted := data[i]
			percentiles[i] = Float(sorted[(len(sorted)*p)/100])
		}

		jm.StatisticsSeries.Percentiles[p] = percentiles
	}

	return true
}


I've read the full file. Let me analyze each function for similar bugs and update the plan.

The file has 5 functions that operate on series data. Let me check them systematically:

**1. `AddStatisticsSeries()` (lines 188-271)** — Computes min/median/max across ALL series at each timestep. Uses `jm.Series` throughout, which is **correct** here because it intentionally aggregates across all series (not per-host). It properly computes `n` (max) and `m` (min) from `jm.Series`, sizes arrays to `n`, and only accesses `Data[i]` when `i < m`. Safe.

**2. `AddNodeScope()` (lines 273-339)** — Already fixed.

**3. `RoundMetricStats()` (lines 341-354)** — Only touches `Statistics` structs, no data array indexing. Safe.

**4. `RoundScopedMetricStats()` (lines 356-370)** — Same, only statistics. Safe.

**5. `AddPercentiles()` (lines 372-424)** — Computes percentiles across ALL series. Uses `jm.Series` which is correct (not per-host). Has explicit bounds check at line 396: `if i < len(series.Data)`. Safe.

Let me update the plan with these findings.


Edit

# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)

## Context

The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).

## Root Cause (in cc-lib)

`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:

### Bug 1 — The crash (line 327)
- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)
- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname
- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data

### Bug 2 — Logic error (lines 318-323)
- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)
- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics

## Fix (in cc-lib `schema/metrics.go`)

Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):

```go
// Before (BUGGY):
n, m := 0, len(jm.Series[0].Data)
for _, series := range jm.Series {
    ...
}
i, data := 0, make([]Float, len(series[0].Data))
for ; i < m; i++ {
    x := Float(0.0)
    for _, series := range jm.Series {
        x += series.Data[i]
    }
    data[i] = x
}

// After (FIXED):
n, m := 0, len(series[0].Data)
for _, s := range series {
    if len(s.Data) > n {
        n = len(s.Data)
    }
    if len(s.Data) < m {
        m = len(s.Data)
    }
}
i, data := 0, make([]Float, n)
for ; i < m; i++ {
    x := Float(0.0)
    for _, s := range series {
        x += s.Data[i]
    }
    data[i] = x
}
```

Key changes:
1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`
2. `data` sized to `n` (max length for this host), not `series[0].Data` length
3. Summation iterates local `series`, not `jm.Series`

## Where to Apply

The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:

1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend
2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug

**Recommended: Option 1** — fix cc-lib.

## Verification

- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation
- Integration: archive a job with mixed-length metric data

# Analysis: Similar Bugs in cc-lib `schema/metrics.go`

## Context

After fixing the `AddNodeScope` panic (index out of range due to using `jm.Series` instead of local `series`), we're checking if the same class of bug exists elsewhere in the file.

## Already Fixed: `AddNodeScope` (lines 273-339)

The fix is already applied — `n`/`m` and summation now use local `series` (per-host) instead of `jm.Series` (all hosts). Tests pass.

## Audit of All Functions in `metrics.go`

### `AddStatisticsSeries()` (lines 188-271) — **NO BUG**
- Computes min/median/max **across ALL series** at each timestep — using `jm.Series` is **intentionally correct**
- `n`/`m` computed from `jm.Series` (line 193-201): correct, since stats span all series
- Data access `jm.Series[j].Data[i]` (line 212): guarded by `i < m` where `m` is the minimum series length — safe
- NaN padding loop (line 243): `i` goes from `m` to `n`, arrays sized to `n` — safe
- Entry guard (line 189): `len(jm.Series) < 4` prevents `Series[0]` panic

### `AddPercentiles()` (lines 372-424) — **NO BUG**
- Also operates on ALL series (percentiles across the full set) — `jm.Series` correct
- Explicit bounds check at line 396: `if i < len(series.Data)` — safe
- `sorted` at line 417 is never empty because `n` = max series length, so at least one series contributes data at every index

### `RoundMetricStats()` (lines 341-354) — **NO BUG**
- Only modifies `Statistics` structs (Min/Avg/Max), no data array indexing

### `RoundScopedMetricStats()` (lines 356-370) — **NO BUG**
- Same pattern, only touches `Data` field which is a `*MetricStatistics`, not a slice

## Conclusion

**`AddNodeScope` was the only function with this bug class.** The other functions either:
1. Intentionally operate on ALL series (not per-host), so `jm.Series` is correct
2. Have proper bounds checks before data access
3. Don't index into data arrays at all

No further changes needed in this file.


The file /Users/jan/.claude/plans/frolicking-splashing-kay.md has been updated successfully.

# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)

## Context

The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).

## Root Cause (in cc-lib)

`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:

### Bug 1 — The crash (line 327)
- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)
- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname
- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data

### Bug 2 — Logic error (lines 318-323)
- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)
- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics

## Fix (in cc-lib `schema/metrics.go`)

Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):

```go
// Before (BUGGY):
n, m := 0, len(jm.Series[0].Data)
for _, series := range jm.Series {
    ...
}
i, data := 0, make([]Float, len(series[0].Data))
for ; i < m; i++ {
    x := Float(0.0)
    for _, series := range jm.Series {
        x += series.Data[i]
    }
    data[i] = x
}

// After (FIXED):
n, m := 0, len(series[0].Data)
for _, s := range series {
    if len(s.Data) > n {
        n = len(s.Data)
    }
    if len(s.Data) < m {
        m = len(s.Data)
    }
}
i, data := 0, make([]Float, n)
for ; i < m; i++ {
    x := Float(0.0)
    for _, s := range series {
        x += s.Data[i]
    }
    data[i] = x
}
```

Key changes:
1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`
2. `data` sized to `n` (max length for this host), not `series[0].Data` length
3. Summation iterates local `series`, not `jm.Series`

## Where to Apply

The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:

1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend
2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug

**Recommended: Option 1** — fix cc-lib.

## Verification

- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation
- Integration: archive a job with mixed-length metric data

# Analysis: Similar Bugs in cc-lib `schema/metrics.go`

## Context

After fixing the `AddNodeScope` panic (index out of range due to using `jm.Series` instead of local `series`), we're checking if the same class of bug exists elsewhere in the file.

## Already Fixed: `AddNodeScope` (lines 273-339)

The fix is already applied — `n`/`m` and summation now use local `series` (per-host) instead of `jm.Series` (all hosts). Tests pass.

## Audit of All Functions in `metrics.go`

### `AddStatisticsSeries()` (lines 188-271) — **NO BUG**
- Computes min/median/max **across ALL series** at each timestep — using `jm.Series` is **intentionally correct**
- `n`/`m` computed from `jm.Series` (line 193-201): correct, since stats span all series
- Data access `jm.Series[j].Data[i]` (line 212): guarded by `i < m` where `m` is the minimum series length — safe
- NaN padding loop (line 243): `i` goes from `m` to `n`, arrays sized to `n` — safe
- Entry guard (line 189): `len(jm.Series) < 4` prevents `Series[0]` panic

### `AddPercentiles()` (lines 372-424) — **NO BUG**
- Also operates on ALL series (percentiles across the full set) — `jm.Series` correct
- Explicit bounds check at line 396: `if i < len(series.Data)` — safe
- `sorted` at line 417 is never empty because `n` = max series length, so at least one series contributes data at every index

### `RoundMetricStats()` (lines 341-354) — **NO BUG**
- Only modifies `Statistics` structs (Min/Avg/Max), no data array indexing

### `RoundScopedMetricStats()` (lines 356-370) — **NO BUG**
- Same pattern, only touches `Data` field which is a `*MetricStatistics`, not a slice

## Conclusion

**`AddNodeScope` was the only function with this bug class.** The other functions either:
1. Intentionally operate on ALL series (not per-host), so `jm.Series` is correct
2. Have proper bounds checks before data access
3. Don't index into data arrays at all

No further changes needed in this file.

-# Fix: Panic in `AddNodeScope` (cc-lib v2.8.2)

+# Analysis: Similar Bugs in cc-lib `schema/metrics.go`

-The archiver crashes with `panic: runtime error: index out of range [4] with length 4` at `schema/metrics.go:327` in `AddNodeScope`. This happens during job archiving when `LoadData` calls `jd.AddNodeScope("flops_any")` or `jd.AddNodeScope("mem_bw")` (dataLoader.go:214-215).

+After fixing the `AddNodeScope` panic (index out of range due to using `jm.Series` instead of local `series`), we're checking if the same class of bug exists elsewhere in the file.

+## Already Fixed: `AddNodeScope` (lines 273-339)

-`AddNodeScope` aggregates sub-node metrics (e.g., per-core) into node-level values. It has **two bugs**, both in `schema/metrics.go:299-328`:

+The fix is already applied — `n`/`m` and summation now use local `series` (per-host) instead of `jm.Series` (all hosts). Tests pass.

-- Line 317: `data` is allocated with `len(series[0].Data)` — the current hostname's first sub-series length (e.g., 4)

-- Lines 307-315: `n` (max data length) is computed across **ALL** `jm.Series` (all hostnames), not just the current hostname

-- Lines 326-328: The loop `for ; i < n; i++ { data[i] = NaN }` writes past `data`'s bounds when another hostname has longer data

+## Audit of All Functions in `metrics.go`

-### Bug 2 — Logic error (lines 318-323)

-- The summation loop iterates `jm.Series` (ALL hostnames' sub-node series) instead of the local `series` (current hostname only)

-- This means each node-level value is the sum across ALL hosts, not just that host's sub-node metrics

+### `AddStatisticsSeries()` (lines 188-271) — **NO BUG**

+- Computes min/median/max **across ALL series** at each timestep — using `jm.Series` is **intentionally correct**

+- `n`/`m` computed from `jm.Series` (line 193-201): correct, since stats span all series

+- Data access `jm.Series[j].Data[i]` (line 212): guarded by `i < m` where `m` is the minimum series length — safe

+- NaN padding loop (line 243): `i` goes from `m` to `n`, arrays sized to `n` — safe

+- Entry guard (line 189): `len(jm.Series) < 4` prevents `Series[0]` panic

+### `AddPercentiles()` (lines 372-424) — **NO BUG**

+- Also operates on ALL series (percentiles across the full set) — `jm.Series` correct

+- Explicit bounds check at line 396: `if i < len(series.Data)` — safe

+- `sorted` at line 417 is never empty because `n` = max series length, so at least one series contributes data at every index

-Replace lines 307-324 to use the local `series` variable (current hostname) instead of `jm.Series` (all hostnames):

+### `RoundMetricStats()` (lines 341-354) — **NO BUG**

+- Only modifies `Statistics` structs (Min/Avg/Max), no data array indexing

-i, data := 0, make([]Float, len(series[0].Data))

+### `RoundScopedMetricStats()` (lines 356-370) — **NO BUG**

+- Same pattern, only touches `Data` field which is a `*MetricStatistics`, not a slice

-1. `n`/`m` computed from local `series` (current hostname), not `jm.Series`

-2. `data` sized to `n` (max length for this host), not `series[0].Data` length

-3. Summation iterates local `series`, not `jm.Series`

+**`AddNodeScope` was the only function with this bug class.** The other functions either:

+1. Intentionally operate on ALL series (not per-host), so `jm.Series` is correct

+2. Have proper bounds checks before data access

-The fix is in **cc-lib** (`schema/metrics.go:307-324`), not cc-backend. Options:

-1. **Fix in cc-lib** — update `AddNodeScope` and release a new version, then bump the dependency in cc-backend

-2. **Workaround in cc-backend** — add a defensive wrapper in `dataLoader.go` before calling `AddNodeScope` (e.g., check series lengths), but this only masks the logic bug

-**Recommended: Option 1** — fix cc-lib.

-- Unit test: create a `JobData` with sub-node metrics where different hosts have different data lengths, call `AddNodeScope`, verify no panic and correct aggregation

-- Integration: archive a job with mixed-length metric data

+No further changes needed in this file.


ToolSearch

Question: select:ExitPlanMode


Question: select:ExitPlanMode


**`AddNodeScope` was the only function with this bug class.** The other 4 functions in the file are clean:

- **`AddStatisticsSeries`** and **`AddPercentiles`** — intentionally operate on ALL series (not per-host), so using `jm.Series` is correct. Both have proper bounds checks.
- **`RoundMetricStats`** and **`RoundScopedMetricStats`** — only touch `Statistics` structs, no data array indexing at all.

No further code changes needed. The fix we already applied is the only instance.


ExitPlanMode

# Analysis: Similar Bugs in cc-lib `schema/metrics.go`

## Context

After fixing the `AddNodeScope` panic (index out of range due to using `jm.Series` instead of local `series`), we're checking if the same class of bug exists elsewhere in the file.

## Already Fixed: `AddNodeScope` (lines 273-339)

The fix is already applied — `n`/`m` and summation now use local `series` (per-host) instead of `jm.Series` (all hosts). Tests pass.

## Audit of All Functions in `metrics.go`

### `AddStatisticsSeries()` (lines 188-271) — **NO BUG**
- Computes min/median/max **across ALL series** at each timestep — using `jm.Series` is **intentionally correct**
- `n`/`m` computed from `jm.Series` (line 193-201): correct, since stats span all series
- Data access `jm.Series[j].Data[i]` (line 212): guarded by `i < m` where `m` is the minimum series length — safe
- NaN padding loop (line 243): `i` goes from `m` to `n`, arrays sized to `n` — safe
- Entry guard (line 189): `len(jm.Series) < 4` prevents `Series[0]` panic

### `AddPercentiles()` (lines 372-424) — **NO BUG**
- Also operates on ALL series (percentiles across the full set) — `jm.Series` correct
- Explicit bounds check at line 396: `if i < len(series.Data)` — safe
- `sorted` at line 417 is never empty because `n` = max series length, so at least one series contributes data at every index

### `RoundMetricStats()` (lines 341-354) — **NO BUG**
- Only modifies `Statistics` structs (Min/Avg/Max), no data array indexing

### `RoundScopedMetricStats()` (lines 356-370) — **NO BUG**
- Same pattern, only touches `Data` field which is a `*MetricStatistics`, not a slice

## Conclusion

**`AddNodeScope` was the only function with this bug class.** The other functions either:
1. Intentionally operate on ALL series (not per-host), so `jm.Series` is correct
2. Have proper bounds checks before data access
3. Don't index into data arrays at all

No further changes needed in this file.


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.


[Request interrupted by user for tool use]


Check if the previous bug is fixed no in the current cc-lib used


Bash

Find Go module cache path


Check cc-lib version in go.mod


github.com/ClusterCockpit/cc-lib/v2 v2.9.0


cc-backend is on **v2.9.0** now (was v2.8.2 when the bug was found). Let me check if that version has the fix.


   299→	for hostname, series := range hosts {
   300→		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
   301→		for _, series := range series {
   302→			sum += series.Statistics.Avg
   303→			min = math.Min(min, series.Statistics.Min)
   304→			max = math.Max(max, series.Statistics.Max)
   305→		}
   306→
   307→		n, m := 0, len(series[0].Data)
   308→		for _, s := range series {
   309→			if len(s.Data) > n {
   310→				n = len(s.Data)
   311→			}
   312→			if len(s.Data) < m {
   313→				m = len(s.Data)
   314→			}
   315→		}
   316→
   317→		i, data := 0, make([]Float, n)
   318→		for ; i < m; i++ {
   319→			x := Float(0.0)
   320→			for _, s := range series {
   321→				x += s.Data[i]
   322→			}
   323→			data[i] = x
   324→		}
   325→
   326→		for ; i < n; i++ {
   327→			data[i] = NaN
   328→		}

	for hostname, series := range hosts {
		min, sum, max := math.MaxFloat32, 0.0, -math.MaxFloat32
		for _, series := range series {
			sum += series.Statistics.Avg
			min = math.Min(min, series.Statistics.Min)
			max = math.Max(max, series.Statistics.Max)
		}

		n, m := 0, len(series[0].Data)
		for _, s := range series {
			if len(s.Data) > n {
				n = len(s.Data)
			}
			if len(s.Data) < m {
				m = len(s.Data)
			}
		}

		i, data := 0, make([]Float, n)
		for ; i < m; i++ {
			x := Float(0.0)
			for _, s := range series {
				x += s.Data[i]
			}
			data[i] = x
		}

		for ; i < n; i++ {
			data[i] = NaN
		}


**Yes, the fix is already included in cc-lib v2.9.0** which cc-backend currently uses. The code at lines 307-324 matches the fixed version:

- Line 307: `n, m := 0, len(series[0].Data)` — uses local `series` (not `jm.Series`)
- Line 308: `for _, s := range series` — iterates local `series`
- Line 317: `make([]Float, n)` — sized to `n` (max length)
- Line 320: `for _, s := range series` — sums local `series`

No action needed — cc-backend's dependency is already on the fixed version.