guidepost 0.3.1__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.3/MANIFEST.in +4 -0
  2. {guidepost-0.3.1 → guidepost-0.3.3}/PKG-INFO +2 -11
  3. {guidepost-0.3.1 → guidepost-0.3.3}/README.md +1 -10
  4. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/guidepost.py +26 -1
  5. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/seriation.py +101 -10
  6. guidepost-0.3.3/guidepost/static/guidepost.js +23226 -0
  7. guidepost-0.3.3/guidepost/static/guidepost.js.map +7 -0
  8. guidepost-0.3.3/guidepost/static/trailmark.js +21 -0
  9. guidepost-0.3.3/guidepost/static/trailmark.js.map +7 -0
  10. guidepost-0.3.3/guidepost/version.py +2 -0
  11. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/PKG-INFO +2 -11
  12. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/SOURCES.txt +6 -0
  13. {guidepost-0.3.1 → guidepost-0.3.3}/setup.py +1 -1
  14. guidepost-0.3.3/tests/test_custom_sort.py +144 -0
  15. {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_list_parsing.py +4 -1
  16. guidepost-0.3.1/guidepost/version.py +0 -2
  17. {guidepost-0.3.1 → guidepost-0.3.3}/LICENSE +0 -0
  18. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/__init__.py +0 -0
  19. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/aggregation.py +0 -0
  20. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/node_layout.py +0 -0
  21. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/trailmark.py +0 -0
  22. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/utils.py +0 -0
  23. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/dependency_links.txt +0 -0
  24. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/requires.txt +0 -0
  25. {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/top_level.txt +0 -0
  26. {guidepost-0.3.1 → guidepost-0.3.3}/pyproject.toml +0 -0
  27. {guidepost-0.3.1 → guidepost-0.3.3}/setup.cfg +0 -0
  28. {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_aggregation.py +0 -0
  29. {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_node_layout.py +0 -0
  30. {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_seriation.py +0 -0
  31. {guidepost-0.3.1 → guidepost-0.3.3}/tutorials/__init__.py +0 -0
@@ -0,0 +1,4 @@
1
+ # `python -m build` builds the wheel from the sdist, so the compiled frontend
2
+ # bundles must be in the sdist for them to reach the installed wheel. They are
3
+ # gitignored (built by esbuild.config.js), so include them explicitly here.
4
+ recursive-include guidepost/static *.js *.js.map
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: guidepost
3
- Version: 0.3.1
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
@@ -50,22 +50,13 @@ import pandas as pd
50
50
  gp = Guidepost()
51
51
  gp.load_data(pd.read_parquet("data/jobs_data.parquet"))
52
52
 
53
- gp.vis_configs = {
54
- 'x': 'start_time', # x-axis (numeric or datetime)
55
- 'y': 'queue_wait', # y-axis (numeric)
56
- 'color': 'nodes_requested', # cell color (numeric)
57
- 'color_agg': 'avg', # aggregation for color
58
- 'categorical': 'user', # bar chart / filter
59
- 'facet_by': 'partition' # splits the data into groups
60
- }
61
-
62
53
  gp # display in a notebook cell
63
54
  ```
64
55
 
65
56
  Brush the heatmap or its histograms, then pull the selected rows back into Python:
66
57
 
67
58
  ```python
68
- df = gp.retrieve_selected_data() # or: gp.selection.dataframe
59
+ df = gp.selection.dataframe
69
60
  ```
70
61
 
71
62
  Input is a `pandas` DataFrame with at least three numeric and two categorical columns (datetime columns are supported on the x-axis).
@@ -19,22 +19,13 @@ import pandas as pd
19
19
  gp = Guidepost()
20
20
  gp.load_data(pd.read_parquet("data/jobs_data.parquet"))
21
21
 
22
- gp.vis_configs = {
23
- 'x': 'start_time', # x-axis (numeric or datetime)
24
- 'y': 'queue_wait', # y-axis (numeric)
25
- 'color': 'nodes_requested', # cell color (numeric)
26
- 'color_agg': 'avg', # aggregation for color
27
- 'categorical': 'user', # bar chart / filter
28
- 'facet_by': 'partition' # splits the data into groups
29
- }
30
-
31
22
  gp # display in a notebook cell
32
23
  ```
33
24
 
34
25
  Brush the heatmap or its histograms, then pull the selected rows back into Python:
35
26
 
36
27
  ```python
37
- df = gp.retrieve_selected_data() # or: gp.selection.dataframe
28
+ df = gp.selection.dataframe
38
29
  ```
39
30
 
40
31
  Input is a `pandas` DataFrame with at least three numeric and two categorical columns (datetime columns are supported on the x-axis).
@@ -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.
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
+
26
44
 
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``.
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:
@@ -167,11 +220,49 @@ def _seriate_component(members, A, node_list, SpectralEmbedding):
167
220
  return sorted(members, key=lambda i: node_list[i])
168
221
 
169
222
  sub = A[members][:, members].toarray()
223
+
224
+ # On symmetric structures (e.g. a clique) the smallest non-zero Laplacian
225
+ # eigenvalue is degenerate, so the 1-D spectral coordinate is not unique:
226
+ # the eigensolver returns an arbitrary basis vector from that eigenspace,
227
+ # which varies across runs, platforms, and BLAS threading. There is no
228
+ # canonical order in that case, so fall back to deterministic name order.
229
+ # (EigenVALUES are stable even when the corresponding eigenVECTORS are not,
230
+ # so this test is itself reproducible.)
231
+ if _spectral_order_is_degenerate(sub):
232
+ return sorted(members, key=lambda i: node_list[i])
233
+
170
234
  emb = SpectralEmbedding(n_components=1, affinity="precomputed", random_state=0)
171
235
  coords = emb.fit_transform(sub)[:, 0]
172
- local = sorted(range(len(members)), key=lambda t: (coords[t], node_list[members[t]]))
236
+ # Round so any residual floating-point noise on near-tied nodes collapses and
237
+ # the name-based secondary key decides their order deterministically.
238
+ local = sorted(
239
+ range(len(members)),
240
+ key=lambda t: (round(float(coords[t]), 9), node_list[members[t]]),
241
+ )
173
242
  seq = [members[t] for t in local]
174
243
  # Eigenvectors carry a sign ambiguity; canonicalize orientation by name.
175
244
  if node_list[seq[0]] > node_list[seq[-1]]:
176
245
  seq.reverse()
177
246
  return seq
247
+
248
+
249
+ def _spectral_order_is_degenerate(sub, tol=1e-9):
250
+ """True when the 1-D spectral embedding of ``sub`` is ill-defined.
251
+
252
+ The seriation uses the eigenvector of the smallest non-zero eigenvalue of
253
+ the symmetric normalized Laplacian. When that eigenvalue is (near-)repeated,
254
+ the eigenvector — and hence the node order — is not unique. We detect that
255
+ from the eigenvalue *spectrum*, which ``eigvalsh`` returns deterministically
256
+ regardless of solver/threading, unlike the eigenvectors themselves.
257
+ """
258
+ deg = sub.sum(axis=1)
259
+ if np.any(deg <= 0):
260
+ return True # an isolated node has no meaningful spectral position
261
+ d_inv_sqrt = 1.0 / np.sqrt(deg)
262
+ laplacian = np.eye(sub.shape[0]) - (d_inv_sqrt[:, None] * sub * d_inv_sqrt[None, :])
263
+ evals = np.linalg.eigvalsh(laplacian) # ascending; evals[0] ~ 0 (connected)
264
+ if len(evals) < 3:
265
+ return False
266
+ # Degenerate iff the 2nd and 3rd smallest eigenvalues coincide, i.e. the
267
+ # smallest non-zero eigenvalue has multiplicity > 1.
268
+ return bool((evals[2] - evals[1]) < tol)