guidepost 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {guidepost-0.3.2/guidepost.egg-info → guidepost-0.3.3}/PKG-INFO +1 -1
  2. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/guidepost.py +26 -1
  3. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/seriation.py +63 -10
  4. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/static/guidepost.js +63 -19
  5. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/static/guidepost.js.map +2 -2
  6. guidepost-0.3.3/guidepost/version.py +2 -0
  7. {guidepost-0.3.2 → guidepost-0.3.3/guidepost.egg-info}/PKG-INFO +1 -1
  8. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost.egg-info/SOURCES.txt +1 -0
  9. guidepost-0.3.3/tests/test_custom_sort.py +144 -0
  10. guidepost-0.3.2/guidepost/version.py +0 -2
  11. {guidepost-0.3.2 → guidepost-0.3.3}/LICENSE +0 -0
  12. {guidepost-0.3.2 → guidepost-0.3.3}/MANIFEST.in +0 -0
  13. {guidepost-0.3.2 → guidepost-0.3.3}/README.md +0 -0
  14. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/__init__.py +0 -0
  15. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/aggregation.py +0 -0
  16. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/node_layout.py +0 -0
  17. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/static/trailmark.js +0 -0
  18. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/static/trailmark.js.map +0 -0
  19. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/trailmark.py +0 -0
  20. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost/utils.py +0 -0
  21. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost.egg-info/dependency_links.txt +0 -0
  22. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost.egg-info/requires.txt +0 -0
  23. {guidepost-0.3.2 → guidepost-0.3.3}/guidepost.egg-info/top_level.txt +0 -0
  24. {guidepost-0.3.2 → guidepost-0.3.3}/pyproject.toml +0 -0
  25. {guidepost-0.3.2 → guidepost-0.3.3}/setup.cfg +0 -0
  26. {guidepost-0.3.2 → guidepost-0.3.3}/setup.py +0 -0
  27. {guidepost-0.3.2 → guidepost-0.3.3}/tests/test_aggregation.py +0 -0
  28. {guidepost-0.3.2 → guidepost-0.3.3}/tests/test_list_parsing.py +0 -0
  29. {guidepost-0.3.2 → guidepost-0.3.3}/tests/test_node_layout.py +0 -0
  30. {guidepost-0.3.2 → guidepost-0.3.3}/tests/test_seriation.py +0 -0
  31. {guidepost-0.3.2 → guidepost-0.3.3}/tutorials/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: guidepost
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Guidepost. An overview visualization for understanding supercomputer queue data.
5
5
  Home-page: https://github.com/cscully-allison/guidepost
6
6
  Author: Connor Scully-Allison
@@ -77,6 +77,16 @@ class Guidepost(anywidget.AnyWidget):
77
77
  # identifiers the name heuristic misses, or to override an unwanted
78
78
  # auto-classification. Mirrors list_columns; pulled before super().
79
79
  categorical_columns = kwargs.pop("categorical_columns", None)
80
+ # Optional override for list-column x-axis ordering, replacing the
81
+ # built-in node-layout / seriation heuristics — for ordinal-but-not-
82
+ # hierarchical values the heuristics can't order (e.g. nodes named
83
+ # "1","2","3"). Either a single callable applied to every list column,
84
+ # or a {column: callable} dict for per-column control. Each callable is
85
+ # a full-list transform: it receives the list of distinct category
86
+ # values and returns them reordered. A callable can't be JSON-synced,
87
+ # so like list_columns this is a plain Python attribute (not a trait);
88
+ # it runs in load_data and only its *result* ships to the frontend.
89
+ sort_by = kwargs.pop("sort_by", None)
80
90
  super().__init__(*args, **kwargs)
81
91
  self._categorical_columns = set(categorical_columns) if categorical_columns else set()
82
92
  self._list_columns = list(list_columns) if list_columns else []
@@ -85,6 +95,20 @@ class Guidepost(anywidget.AnyWidget):
85
95
  # before the first load don't fail.
86
96
  self._effective_list_columns = list(self._list_columns)
87
97
  self._list_delimiter = list_delimiter or ","
98
+ # Validate up front so a bad sort_by fails at construction, not silently
99
+ # at render. None | callable | {str: callable} are the only valid shapes.
100
+ if sort_by is not None:
101
+ if isinstance(sort_by, dict):
102
+ bad = [k for k, v in sort_by.items() if not callable(v)]
103
+ if bad:
104
+ raise TypeError(
105
+ "sort_by dict values must be callables; non-callable "
106
+ f"value(s) for column(s): {bad}")
107
+ elif not callable(sort_by):
108
+ raise TypeError(
109
+ "sort_by must be a callable or a {column: callable} dict, "
110
+ f"got {type(sort_by).__name__}")
111
+ self._sort_by = sort_by
88
112
  # DuckDB-backed aggregator. Lazily created when the first DataFrame
89
113
  # is loaded — keeping it None until then so widgets constructed
90
114
  # without data don't pay the import/registration cost.
@@ -194,7 +218,8 @@ class Guidepost(anywidget.AnyWidget):
194
218
  # that retains every node); arbitrary categories fall back to spectral
195
219
  # seriation (ships `category_score` for the column cap). Both share
196
220
  # `category_order` for the kept/ordered sequence.
197
- ordering = compute_category_ordering(o_df, self._effective_list_columns)
221
+ ordering = compute_category_ordering(
222
+ o_df, self._effective_list_columns, sort_overrides=self._sort_by)
198
223
  for col, info in ordering.items():
199
224
  if col not in summary_stats:
200
225
  continue
@@ -15,31 +15,84 @@ Both derive from the same node x node co-occurrence matrix. sklearn/scipy are
15
15
  imported lazily so the cost is only paid when a list column is present.
16
16
  """
17
17
 
18
+ import warnings
19
+
18
20
  import numpy as np
19
21
 
20
22
  from .node_layout import compute_node_layout
21
23
 
22
24
 
23
- def compute_category_ordering(o_df, list_columns):
24
- """Dispatch ordering per list column: prefer structure-aware node-name
25
- layout, fall back to spectral co-occurrence seriation.
26
-
27
- Returns ``{col: info}`` where *info* is either the structured shape
28
- ``{"order", "hierarchy", "levels"}`` (node names follow a hardware-layout
29
- convention) or the seriation shape ``{"order", "score"}`` (arbitrary
30
- categories). The frontend treats both uniformly via ``category_order`` and
31
- branches on the presence of ``category_hierarchy``.
25
+ def _resolve_override(sort_overrides, col):
26
+ """Resolve the user sort override applicable to *col*, if any. A bare
27
+ callable applies to every column; a dict maps column name -> callable."""
28
+ if sort_overrides is None:
29
+ return None
30
+ if callable(sort_overrides):
31
+ return sort_overrides
32
+ if isinstance(sort_overrides, dict):
33
+ return sort_overrides.get(col)
34
+ return None
35
+
36
+
37
+ def _apply_override(fn, names):
38
+ """Run a user override (full-list transform) over the distinct category
39
+ names, coercing the result to the String() keys the frontend uses. Raises
40
+ on a non-iterable return so the caller can fall back to the heuristics."""
41
+ ordered = fn(list(names))
42
+ return [str(k) for k in ordered]
43
+
44
+
45
+ def compute_category_ordering(o_df, list_columns, sort_overrides=None):
46
+ """Dispatch ordering per list column: a user override (if provided) wins,
47
+ else prefer structure-aware node-name layout, else fall back to spectral
48
+ co-occurrence seriation.
49
+
50
+ Returns ``{col: info}`` where *info* is one of:
51
+ - ``{"order"}`` — a user ``sort_overrides`` transform for this column,
52
+ - ``{"order", "hierarchy", "levels"}`` — node names follow a hardware-layout
53
+ convention, or
54
+ - ``{"order", "score"}`` — arbitrary categories (seriation).
55
+ The frontend treats all three uniformly via ``category_order`` and branches
56
+ on the presence of ``category_hierarchy``.
57
+
58
+ *sort_overrides* is ``None``, a single callable applied to every list
59
+ column, or a ``{column: callable}`` dict. Each callable receives the list
60
+ of distinct category values and returns them reordered. A callable that
61
+ raises (or returns a non-iterable) falls back to the heuristics for that
62
+ column with a warning, so a bad override never aborts data loading.
32
63
  """
33
64
  if not list_columns:
34
65
  return {}
35
66
 
67
+ # Warn on dict keys that don't name a list column (typo / out of scope):
68
+ # they'd otherwise be silently ignored.
69
+ if isinstance(sort_overrides, dict):
70
+ unknown = [c for c in sort_overrides if c not in list_columns]
71
+ if unknown:
72
+ warnings.warn(
73
+ "sort_by names column(s) that are not list columns and will be "
74
+ f"ignored: {unknown}. Custom sort applies to list columns only.")
75
+
36
76
  out = {}
37
77
  fallback_cols = []
38
78
  for col in list_columns:
39
79
  if col not in o_df.columns:
40
80
  continue
41
81
  names = _distinct_node_names(o_df[col])
42
- layout = compute_node_layout(names) if names else None
82
+ if not names:
83
+ continue
84
+
85
+ override = _resolve_override(sort_overrides, col)
86
+ if override is not None:
87
+ try:
88
+ out[col] = {"order": _apply_override(override, names)}
89
+ continue
90
+ except Exception as exc: # noqa: BLE001 - fall back, never abort load
91
+ warnings.warn(
92
+ f"sort_by override for column '{col}' raised {exc!r}; "
93
+ "falling back to the default ordering heuristic.")
94
+
95
+ layout = compute_node_layout(names)
43
96
  if layout is not None:
44
97
  out[col] = layout
45
98
  else:
@@ -16630,15 +16630,23 @@ var JSModel = class {
16630
16630
  return data;
16631
16631
  }
16632
16632
  /**
16633
- * Sanitizes data for log scale by replacing zero values with one.
16633
+ * Sanitizes data for a log scale by clamping any value below the log floor
16634
+ * up to the floor. A log axis starts at `log_values_floor`, so a value in
16635
+ * [0, floor) can't be placed on it — binValues would drop it entirely (no
16636
+ * bin matches `val < thresholds[0]`), silently losing real records (e.g.
16637
+ * sub-1-Wh energy for standby/short jobs). Clamping puts them in bin 0 so
16638
+ * cells, the right histogram, the count strip and brush/pin selection all
16639
+ * agree. null/NaN are guarded (note `null < 1` is true in JS) so genuine
16640
+ * missing values stay unbinned rather than masquerading as floor values.
16634
16641
  * @param {Array} data - The data to sanitize.
16635
16642
  * @param {string} col - The column to sanitize.
16636
16643
  * @returns {Array} - The sanitized data.
16637
16644
  */
16638
16645
  sanitize_data_for_log(data, col) {
16639
16646
  data.forEach((element, i, arr) => {
16640
- if (element[col] == 0) {
16641
- arr[i][col] = 1;
16647
+ const v = element[col];
16648
+ if (v != null && !Number.isNaN(v) && v < this.log_values_floor) {
16649
+ arr[i][col] = this.log_values_floor;
16642
16650
  }
16643
16651
  });
16644
16652
  return data;
@@ -17864,6 +17872,14 @@ var JSModel = class {
17864
17872
  * displayed cells whose column is in col_range (empty = all columns) and
17865
17873
  * whose row is in y_range (empty = all rows). Grouped columns carry
17866
17874
  * lo/hi (node-index overlap); scalar columns are matched by array index.
17875
+ *
17876
+ * A column's `bins` only hold rows with a non-null, in-range y value, so
17877
+ * summing bin indices silently drops null/NaN-y rows — common for e.g.
17878
+ * Failed jobs that never recorded a runtime metric. When the brush spans
17879
+ * the FULL y extent (or sets no y restriction), the user is selecting whole
17880
+ * columns, so use `col.indices` (the column's full membership) to match the
17881
+ * displayed "(N records)" count and column-pin selection. A partial y-brush
17882
+ * still restricts to the covered cells (null-y rows have no cell to brush).
17867
17883
  */
17868
17884
  _band_box_indices(facet) {
17869
17885
  const cols = this.current_detail_columns(facet) || [];
@@ -17871,6 +17887,8 @@ var JSModel = class {
17871
17887
  const yr = this.brushed_ranges[facet].y_range;
17872
17888
  const has_col = cr.length === 2;
17873
17889
  const has_y = yr.length === 2;
17890
+ const nbins = cols[0] && cols[0].bins && cols[0].bins.length || 0;
17891
+ const y_covers_all = !has_y || yr[1] <= 0 && yr[0] >= nbins - 1;
17874
17892
  const ids = /* @__PURE__ */ new Set();
17875
17893
  for (let ci = 0; ci < cols.length; ci++) {
17876
17894
  const col = cols[ci];
@@ -17880,8 +17898,13 @@ var JSModel = class {
17880
17898
  in_col = col.lo != null ? col.lo <= cr[1] && col.hi >= cr[0] : ci >= cr[0] && ci <= cr[1];
17881
17899
  }
17882
17900
  if (!in_col) continue;
17901
+ if (y_covers_all) {
17902
+ const idx = col.indices;
17903
+ if (idx) for (let i = 0; i < idx.length; i++) ids.add(idx[i]);
17904
+ continue;
17905
+ }
17883
17906
  for (let row = 0; row < col.bins.length; row++) {
17884
- if (has_y && !(row >= yr[1] && row < yr[0])) continue;
17907
+ if (!(row >= yr[1] && row < yr[0])) continue;
17885
17908
  const idx = col.bins[row].indices;
17886
17909
  if (!idx) continue;
17887
17910
  for (let i = 0; i < idx.length; i++) ids.add(idx[i]);
@@ -18626,20 +18649,13 @@ var Heatmap = class {
18626
18649
  let x_offset = X_VARIABLE_OFFSET + OVERVIEW_LAYOUT.outer_margin;
18627
18650
  let y_offset = Y_VARIABLE_OFFSET + OVERVIEW_LAYOUT.outer_margin + TOP_MARGIN;
18628
18651
  let view = this.parent.append("g").attr("class", "heatmap").attr("transform", (d, i) => `translate(${x_offset},${y_offset})`).attr("width", this.width).attr("height", this.height);
18652
+ this.view = view;
18629
18653
  let axis_left = d32.axisLeft().scale(this.scale_y_inverse);
18630
18654
  if (this.model.scale_types[this.facet].y.linear) {
18631
18655
  axis_left.tickFormat(d32.format(".2s"));
18632
18656
  }
18633
18657
  view.append("g").attr("class", "left-axis").call(axis_left).attr("transform", `translate(${OVERVIEW_LAYOUT.inner_padding},${0})`);
18634
- if (this.x_is_band) {
18635
- const domain = this.scale_x.domain();
18636
- const max_labels = 40;
18637
- const step = Math.max(1, Math.ceil(domain.length / max_labels));
18638
- const tick_vals = domain.filter((d, i) => i % step === 0);
18639
- view.append("g").attr("class", "bottom-axis").call(d32.axisBottom(this.scale_x).tickValues(tick_vals)).attr("transform", `translate(${0},${OVERVIEW_LAYOUT.height - OVERVIEW_LAYOUT.inner_padding})`).selectAll("text").attr("text-anchor", "start").attr("transform", "rotate(45)").text((d) => typeof d === "string" && d.length > MAX_NODE_LABEL_CHARS ? d.slice(0, MAX_NODE_LABEL_CHARS) + "\u2026" : d);
18640
- } else {
18641
- view.append("g").attr("class", "bottom-axis").call(d32.axisBottom().scale(this.scale_x.scale).ticks(this.scale_x.get_ticks())).attr("transform", `translate(${0},${OVERVIEW_LAYOUT.height - OVERVIEW_LAYOUT.inner_padding})`);
18642
- }
18658
+ this.draw_x_axis();
18643
18659
  const facet_stats = this.model.feature_summary_stats[this.model.vars.facet_by];
18644
18660
  const synthetic_facet = !!(facet_stats && facet_stats.is_synthetic);
18645
18661
  if (!synthetic_facet) {
@@ -18651,7 +18667,25 @@ var Heatmap = class {
18651
18667
  }
18652
18668
  return this.model.vars.y + "(log)";
18653
18669
  }).attr("text-anchor", "middle").attr("transform", `translate(${-10},${this.height / 2}) rotate(270)`);
18654
- this.view = view;
18670
+ }
18671
+ /** Draws (or redraws) the bottom x-axis. Idempotent — selects the existing
18672
+ * `.bottom-axis` group or creates it — so it can be re-called on detail-zoom
18673
+ * re-renders to keep a grouped/list (band) x-axis in sync with the columns. */
18674
+ draw_x_axis() {
18675
+ let axis_g = this.view.select(".bottom-axis");
18676
+ if (axis_g.empty()) {
18677
+ axis_g = this.view.append("g").attr("class", "bottom-axis");
18678
+ }
18679
+ axis_g.attr("transform", `translate(${0},${OVERVIEW_LAYOUT.height - OVERVIEW_LAYOUT.inner_padding})`);
18680
+ if (this.x_is_band) {
18681
+ const domain = this.scale_x.domain();
18682
+ const max_labels = 40;
18683
+ const step = Math.max(1, Math.ceil(domain.length / max_labels));
18684
+ const tick_vals = domain.filter((d, i) => i % step === 0);
18685
+ axis_g.call(d32.axisBottom(this.scale_x).tickValues(tick_vals)).selectAll("text").attr("text-anchor", "start").attr("transform", "rotate(45)").text((d) => typeof d === "string" && d.length > MAX_NODE_LABEL_CHARS ? d.slice(0, MAX_NODE_LABEL_CHARS) + "\u2026" : d);
18686
+ } else {
18687
+ axis_g.call(d32.axisBottom().scale(this.scale_x.scale).ticks(this.scale_x.get_ticks()));
18688
+ }
18655
18689
  }
18656
18690
  /**
18657
18691
  * Draws sparklines (placeholder function).
@@ -19137,6 +19171,7 @@ var Heatmap = class {
19137
19171
  this.model.detail_range[this.facet] = range;
19138
19172
  this._cols_cache = null;
19139
19173
  this.update_scales();
19174
+ this.draw_x_axis();
19140
19175
  this.render();
19141
19176
  }
19142
19177
  /**
@@ -19611,17 +19646,22 @@ var Heatmap = class {
19611
19646
  const ranges = this.model.brushed_ranges[this.facet];
19612
19647
  if (this.x_is_band) {
19613
19648
  const cr = ranges.col_range, yr2 = ranges.y_range;
19649
+ const has_col = cr.length === 2, has_y = yr2.length === 2;
19650
+ if (!has_col && !has_y) {
19651
+ g.call(this.cell_brush.move, null);
19652
+ return;
19653
+ }
19614
19654
  const cols = this.current_columns();
19615
- const covered = cr.length === 2 ? cols.filter((c) => c.lo != null ? c.lo <= cr[1] && c.hi >= cr[0] : true) : cols;
19616
- const idxCovered = cr.length === 2 && cols.length && cols[0].lo == null ? cols.filter((c, i) => i >= cr[0] && i <= cr[1]) : covered;
19617
- if (yr2.length !== 2 || !idxCovered.length) {
19655
+ const covered = has_col ? cols.filter((c) => c.lo != null ? c.lo <= cr[1] && c.hi >= cr[0] : true) : cols;
19656
+ const idxCovered = has_col && cols.length && cols[0].lo == null ? cols.filter((c, i) => i >= cr[0] && i <= cr[1]) : covered;
19657
+ if (!idxCovered.length) {
19618
19658
  g.call(this.cell_brush.move, null);
19619
19659
  return;
19620
19660
  }
19621
19661
  const cw = this.col_width();
19622
19662
  const px0 = Math.min(...idxCovered.map((c) => this.x_pos(c.threshold)));
19623
19663
  const px1 = Math.max(...idxCovered.map((c) => this.x_pos(c.threshold))) + cw;
19624
- const py = [this.scale_y_blocks(yr2[0]), this.scale_y_blocks(yr2[1])];
19664
+ const py = has_y ? [this.scale_y_blocks(yr2[0]), this.scale_y_blocks(yr2[1])] : [OVERVIEW_LAYOUT.inner_padding, OVERVIEW_LAYOUT.height - OVERVIEW_LAYOUT.inner_padding];
19625
19665
  g.call(this.cell_brush.move, [[px0, Math.min(...py)], [px1, Math.max(...py)]]);
19626
19666
  return;
19627
19667
  }
@@ -19676,9 +19716,13 @@ var Heatmap = class {
19676
19716
  hi = Math.max(hi, ci);
19677
19717
  }
19678
19718
  }
19719
+ const full_y0 = OVERVIEW_LAYOUT.inner_padding;
19720
+ const full_y1 = OVERVIEW_LAYOUT.height - OVERVIEW_LAYOUT.inner_padding;
19721
+ const tol = this._cell_row_height(cols);
19722
+ const covers_full_y = y0 <= full_y0 + tol && y1 >= full_y1 - tol;
19679
19723
  ranges.x_range = [];
19680
19724
  ranges.col_range = lo !== Infinity ? [lo, hi] : [];
19681
- ranges.y_range = y_range;
19725
+ ranges.y_range = covers_full_y ? [] : y_range;
19682
19726
  } else {
19683
19727
  const xa = this.scale_x.scale.invert(x0);
19684
19728
  const xb = this.scale_x.scale.invert(x1);