Skip to content

Commit 85023b1

Browse files
committed
Close the boundary audit: digest every shape argument and guard it in CI
Completes the three findings of devguide/python_js_boundary_audit_2026_07.md. The twenty remaining arguments, all in shapes, now have digesters. Contracts follow their call sites: optional flags stay None so the shape applies its own default and reject truthy non-booleans; colours go through normalize_color; opacities are numbers in [0, 1]; atom quads and triplets must hold exactly four and three non-negative indices, so a malformed mesh cannot reach the frontend; normals and directions are 3D vectors with finite components; segments needs at least three to close a ring; length_scale must be positive, since zero or negative would collapse or invert every vector; and color_component is an axis index. NumPy arrays are accepted throughout, because that is how this data arrives. dynamic_region_evaluation_warning now has a handler: a canvas toast naming the region and the timing that blew the budget. The first attempt was to stop emitting it, since the freeze already reaches the user through the message catalog, but the suite rejected that — a test asserts the message is sent. The emission was deliberate and tested; the missing half was the receiver. Both sweeps now run as tests/test_python_js_boundary.py, so the pattern cannot come back unnoticed: one fails if a @digest() argument has no digester, the other if Python emits an op no handler reads. Both are mutation-verified. Also fixes three pre-existing type errors in trajectory-plot-overlay.ts, where narrowing did not carry across an alias.
1 parent b9a8657 commit 85023b1

27 files changed

Lines changed: 771 additions & 19 deletions

devguide/python_js_boundary_audit_2026_07.md

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,25 @@ hit; it is worth re-running when a new enum-like argument is added.
7676
them: dead code in the opposite direction. A reverse sweep (digesters with no
7777
consumer) would find more.
7878

79-
## Status
80-
81-
The six non-shape digesters are **done** (`region`, `fade`, `meta`, `layer`,
82-
`target`, `transaction_id`), each mutation-verified. Re-running sweep 1 now
83-
reports 20 missing arguments instead of 26, all of them in `shapes`.
84-
85-
## Recommendation
86-
87-
1. Fill the remaining 20 digesters, all in `shapes`. The group is homogeneous and
88-
can be done in one pass.
89-
2. Decide on `dynamic_region_evaluation_warning`: surface it in the frontend, or
90-
stop emitting it. A performance warning that reaches nobody is worse than no
91-
warning, because the region silently degrades.
92-
3. Re-run sweeps 1 and 2 in CI. Both are cheap and deterministic, and would have
93-
caught every instance of this pattern found during dogfooding.
79+
## Status: closed
80+
81+
All three findings are resolved.
82+
83+
- **26 missing digesters → 0.** The six non-shape ones came first (`region`,
84+
`fade`, `meta`, `layer`, `target`, `transaction_id`), then the twenty in
85+
`shapes`. Contracts follow their call sites, and booleans are rejected wherever
86+
they would masquerade as a number or an index.
87+
- **The orphan op now has a handler.** `dynamic_region_evaluation_warning` is
88+
surfaced as a canvas toast naming the region and the timing that blew the
89+
budget. The first attempt was to stop emitting it, on the grounds that the
90+
freeze already reaches the user through the message catalog — but the suite
91+
rejected that: `test_over_budget_dynamic_region_freezes_to_static_and_reports_runtime`
92+
asserts the message is sent. The emission was deliberate and tested; what was
93+
missing was the other half. Worth remembering when this sweep flags an orphan:
94+
check for a test asserting the emission before assuming it is dead weight.
95+
- **Sweeps 1 and 2 now run in CI** as `tests/test_python_js_boundary.py`. Both
96+
are mutation-verified: removing a digester or adding an unread op fails them.
97+
98+
Sweep 3 (values Python accepts that the frontend does not know) is **not**
99+
automated: its signal-to-noise ratio is poor and every hit needs manual reading.
100+
Re-run it by hand when adding an enum-like argument that crosses the boundary.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Shared validation for fixed-size groups of atom indices."""
2+
3+
import numpy as np
4+
5+
from ..exceptions import ArgumentError
6+
7+
8+
def digest_fixed_size_index_groups(value, size, argument, caller=None):
9+
"""Return a list of ``size``-long lists of non-negative atom indices.
10+
11+
Accepts any iterable of iterables, including NumPy arrays, and rejects
12+
groups of the wrong length or with non-integer entries, which would
13+
otherwise reach the frontend as a malformed mesh.
14+
"""
15+
if isinstance(value, np.ndarray):
16+
if value.ndim != 2 or value.shape[1] != size:
17+
raise ArgumentError(argument, value=value, caller=caller, message=None)
18+
rows = value.tolist()
19+
elif isinstance(value, (list, tuple)):
20+
rows = list(value)
21+
else:
22+
raise ArgumentError(argument, value=value, caller=caller, message=None)
23+
24+
out = []
25+
for row in rows:
26+
if isinstance(row, np.ndarray):
27+
row = row.tolist()
28+
if not isinstance(row, (list, tuple)) or len(row) != size:
29+
raise ArgumentError(argument, value=value, caller=caller, message=None)
30+
group = []
31+
for index in row:
32+
if isinstance(index, bool) or not isinstance(index, (int, np.integer)):
33+
raise ArgumentError(argument, value=value, caller=caller, message=None)
34+
if int(index) < 0:
35+
raise ArgumentError(argument, value=value, caller=caller, message=None)
36+
group.append(int(index))
37+
out.append(group)
38+
39+
if not out:
40+
raise ArgumentError(argument, value=value, caller=caller, message=None)
41+
return out
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from ...exceptions import ArgumentError
2+
3+
4+
def digest_alpha_alpha_spheres(alpha_alpha_spheres, caller=None):
5+
"""Digest the ``alpha_alpha_spheres`` opacity, a number in ``[0, 1]``.
6+
7+
Booleans are rejected: ``True``/``False`` are ints in Python but not
8+
meaningful opacities.
9+
"""
10+
if alpha_alpha_spheres is None:
11+
return None
12+
13+
if not isinstance(alpha_alpha_spheres, bool) and isinstance(alpha_alpha_spheres, (int, float)):
14+
value = float(alpha_alpha_spheres)
15+
if 0.0 <= value <= 1.0:
16+
return value
17+
18+
raise ArgumentError(
19+
"alpha_alpha_spheres",
20+
value=alpha_alpha_spheres,
21+
caller=caller,
22+
message=" Opacity must be a number between 0 and 1.",
23+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from ...exceptions import ArgumentError
2+
3+
4+
def digest_alpha_atoms(alpha_atoms, caller=None):
5+
"""Digest the ``alpha_atoms`` opacity, a number in ``[0, 1]``.
6+
7+
Booleans are rejected: ``True``/``False`` are ints in Python but not
8+
meaningful opacities.
9+
"""
10+
if alpha_atoms is None:
11+
return None
12+
13+
if not isinstance(alpha_atoms, bool) and isinstance(alpha_atoms, (int, float)):
14+
value = float(alpha_atoms)
15+
if 0.0 <= value <= 1.0:
16+
return value
17+
18+
raise ArgumentError(
19+
"alpha_atoms",
20+
value=alpha_atoms,
21+
caller=caller,
22+
message=" Opacity must be a number between 0 and 1.",
23+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from ...exceptions import ArgumentError
2+
from .._group_indices import digest_fixed_size_index_groups
3+
4+
5+
def digest_atom_quads(atom_quads, caller=None):
6+
"""Digest the atom quadruplets defining tetrahedra.
7+
8+
Each entry must hold exactly four atom indices (non-negative integers).
9+
``None`` means the shape is defined by explicit coordinates instead.
10+
"""
11+
if atom_quads is None:
12+
return None
13+
return digest_fixed_size_index_groups(atom_quads, 4, "atom_quads", caller=caller)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from ...exceptions import ArgumentError
2+
from .._group_indices import digest_fixed_size_index_groups
3+
4+
5+
def digest_atom_triplets(atom_triplets, caller=None):
6+
"""Digest the atom triplets defining triangle faces.
7+
8+
Each entry must hold exactly three atom indices (non-negative integers).
9+
``None`` means the shape is defined by explicit vertices instead.
10+
"""
11+
if atom_triplets is None:
12+
return None
13+
return digest_fixed_size_index_groups(atom_triplets, 3, "atom_triplets", caller=caller)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from molsysviewer.colors import normalize_color
2+
3+
from ...exceptions import ArgumentError
4+
5+
6+
def digest_color_alpha_spheres(color_alpha_spheres, caller=None):
7+
"""Digest the ``color_alpha_spheres`` colour, normalized to a packed integer.
8+
9+
Accepts any colour form the viewer understands (packed int, ``"#rrggbb"``,
10+
a name, an RGB(A) triplet). ``None`` keeps the shape's own default.
11+
"""
12+
if color_alpha_spheres is None:
13+
return None
14+
15+
try:
16+
return normalize_color(color_alpha_spheres)
17+
except Exception as exc:
18+
raise ArgumentError("color_alpha_spheres", value=color_alpha_spheres, caller=caller, message=None) from exc
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from molsysviewer.colors import normalize_color
2+
3+
from ...exceptions import ArgumentError
4+
5+
6+
def digest_color_atoms(color_atoms, caller=None):
7+
"""Digest the ``color_atoms`` colour, normalized to a packed integer.
8+
9+
Accepts any colour form the viewer understands (packed int, ``"#rrggbb"``,
10+
a name, an RGB(A) triplet). ``None`` keeps the shape's own default.
11+
"""
12+
if color_atoms is None:
13+
return None
14+
15+
try:
16+
return normalize_color(color_atoms)
17+
except Exception as exc:
18+
raise ArgumentError("color_atoms", value=color_atoms, caller=caller, message=None) from exc
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from ...exceptions import ArgumentError
2+
3+
4+
def digest_color_component(color_component, caller=None):
5+
"""Digest the vector component driving "component" colouring: 0, 1 or 2 (x, y, z)."""
6+
if isinstance(color_component, bool):
7+
raise ArgumentError("color_component", value=color_component, caller=caller, message=None)
8+
9+
if isinstance(color_component, int) and color_component in (0, 1, 2):
10+
return color_component
11+
12+
raise ArgumentError(
13+
"color_component",
14+
value=color_component,
15+
caller=caller,
16+
message=" The component must be 0, 1 or 2 (x, y or z).",
17+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import numpy as np
2+
3+
from ...exceptions import ArgumentError
4+
5+
6+
def digest_directions(directions, caller=None):
7+
"""Digest a sequence of 3D vectors, one per element.
8+
9+
Returns a list of ``[x, y, z]`` floats. ``None`` means the shape does not
10+
use them. Non-finite components are rejected: they would produce degenerate
11+
geometry in the frontend.
12+
"""
13+
if directions is None:
14+
return None
15+
16+
value = directions
17+
if isinstance(value, np.ndarray):
18+
if value.ndim != 2 or value.shape[1] != 3:
19+
raise ArgumentError("directions", value=directions, caller=caller, message=None)
20+
value = value.tolist()
21+
22+
if not isinstance(value, (list, tuple)) or len(value) == 0:
23+
raise ArgumentError("directions", value=directions, caller=caller, message=None)
24+
25+
out = []
26+
for vector in value:
27+
if isinstance(vector, np.ndarray):
28+
vector = vector.tolist()
29+
if not isinstance(vector, (list, tuple)) or len(vector) != 3:
30+
raise ArgumentError("directions", value=directions, caller=caller, message=None)
31+
components = []
32+
for component in vector:
33+
if isinstance(component, bool) or not isinstance(component, (int, float, np.number)):
34+
raise ArgumentError("directions", value=directions, caller=caller, message=None)
35+
fc = float(component)
36+
if not np.isfinite(fc):
37+
raise ArgumentError("directions", value=directions, caller=caller, message=None)
38+
components.append(fc)
39+
out.append(components)
40+
return out

0 commit comments

Comments
 (0)