Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions scripts/notebook_tools/check_kernel_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
classes observed:

- Kernel change: ``metadata.kernelspec.name`` or
``metadata.language_info.version`` differs (Python 3.11 -> 3.13).
Outputs may format ``repr(np.float64(0.9999999999999999))`` instead
``metadata.language_info.version`` differs at major.minor level
(Python 3.11 -> 3.13). Patch-only drift (3.13.3 -> 3.13.15) is NOT
flagged: the venv patch evolves under the canonical interpreter and
never changes repr() semantics (#17371). Outputs may format
``repr(np.float64(0.9999999999999999))`` instead
of ``[1.0, 1.0, ...]`` even when the cell computes the same values.
- Float format drift: NumPy 1.x prints ``[1.0, 1.0, 1.0]``; NumPy 2.x
prints ``[1.0, 0.9999999999999999, 1.0]``. The values are within
Expand Down Expand Up @@ -171,13 +174,40 @@ def kernel_info(nb):
}


def _version_prefix(version):
"""Truncate a language version to its major.minor components (#17371).

Patch-level drift (3.13.3 -> 3.13.15) is systemic: the project venv
evolves under the lane's canonical interpreter, so any fresh
re-execution of a notebook whose base stamp is older drifts on the
patch component alone (measured 2026-09-22 on #16858: base 3.13.3,
venv 3.13.15, 10/10 cells, 0 error). A patch bump does not change
repr() semantics; a kernel swap or a major/minor change does. Versions
with fewer than two components ("", "3") are returned verbatim. A JSON
``"version": null`` (valid nbformat, which the ``.get("version", "")``
default does not cover) is read as the empty version rather than
crashing: the guard must emit a finding, never a traceback.
"""
text = str(version or "")
parts = text.split(".")
return ".".join(parts[:2]) if len(parts) >= 2 else text


def diff_kernel(base_info, head_info):
"""Return a list of human-readable kernel-version drift strings."""
"""Return a list of human-readable kernel-version drift strings.

#17371: ``language_info.version`` is compared at major.minor level —
patch-only drift is not a kernel regression. The full versions are
still shown in the message for diagnosis.
"""
diffs = []
if base_info["language_version"] != head_info["language_version"]:
base_ver = _version_prefix(base_info["language_version"])
head_ver = _version_prefix(head_info["language_version"])
if base_ver != head_ver:
diffs.append(
f"language_info.version: {base_info['language_version']!r} -> "
f"{head_info['language_version']!r}"
f"{head_info['language_version']!r} "
f"(major.minor {base_ver} -> {head_ver})"
)
if base_info["kernelspec_name"] != head_info["kernelspec_name"]:
diffs.append(
Expand Down
71 changes: 71 additions & 0 deletions scripts/notebook_tools/tests/test_check_kernel_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,77 @@ def test_diff_kernel_both_change():
assert len(diffs) == 1


# --- #17371: patch-level language_info.version drift is not a regression ---


def test_version_prefix_shapes():
assert ckd._version_prefix("3.13.3") == "3.13"
assert ckd._version_prefix("3.13.15") == "3.13"
assert ckd._version_prefix("3.13.15rc1") == "3.13"
assert ckd._version_prefix("3.13") == "3.13"
assert ckd._version_prefix("3") == "3"
assert ckd._version_prefix("") == ""


def test_version_prefix_null_version_does_not_crash():
# `"version": null` is valid nbformat: the `.get("version", "")`
# extraction default only covers a MISSING key, so the helper received
# None and raised AttributeError -- a traceback where the pre-fix
# comparison emitted a degraded but handled drift (NanoClaw, 2026-09-22).
assert ckd._version_prefix(None) == ""


def test_diff_kernel_null_version_vs_version_flagged():
a = {"language_version": None, "kernelspec_name": "python3"}
b = {"language_version": "3.13.3", "kernelspec_name": "python3"}
diffs = ckd.diff_kernel(a, b)
assert len(diffs) == 1
assert "language_info.version" in diffs[0]
assert "None" in diffs[0] and "3.13.3" in diffs[0]


def test_diff_kernel_null_version_on_both_sides_not_flagged():
a = {"language_version": None, "kernelspec_name": "python3"}
b = {"language_version": None, "kernelspec_name": "python3"}
assert ckd.diff_kernel(a, b) == []


def test_diff_kernel_patch_level_drift_not_flagged():
# Measured on #16858: base stamp 3.13.3, fresh re-exec under the
# project venv 3.13.15 -- same kernel, same repr() semantics.
a = {"language_version": "3.13.3", "kernelspec_name": "python3"}
b = {"language_version": "3.13.15", "kernelspec_name": "python3"}
assert ckd.diff_kernel(a, b) == []


def test_diff_kernel_patch_drift_with_rc_suffix_not_flagged():
a = {"language_version": "3.13.3", "kernelspec_name": "python3"}
b = {"language_version": "3.13.15rc1", "kernelspec_name": "python3"}
assert ckd.diff_kernel(a, b) == []


def test_diff_kernel_minor_change_still_flagged():
a = {"language_version": "3.11.16", "kernelspec_name": "python3"}
b = {"language_version": "3.13.15", "kernelspec_name": "python3"}
diffs = ckd.diff_kernel(a, b)
assert len(diffs) == 1
assert "3.11.16" in diffs[0] and "3.13.15" in diffs[0]
assert "3.11 -> 3.13" in diffs[0]


def test_diff_kernel_major_change_still_flagged():
a = {"language_version": "2.7.18", "kernelspec_name": "python3"}
b = {"language_version": "3.13.15", "kernelspec_name": "python3"}
diffs = ckd.diff_kernel(a, b)
assert len(diffs) == 1


def test_diff_kernel_empty_vs_full_version_flagged():
a = {"language_version": "", "kernelspec_name": "python3"}
b = {"language_version": "3.13.15", "kernelspec_name": "python3"}
assert len(ckd.diff_kernel(a, b)) == 1


def test_float_signatures_matches_array_shape():
nb = _nb("python3", "3.13.3",
["n=5: distances = [1.0, 0.9999999999999999, 1.0, 1.0, 1.0]\n"])
Expand Down
Loading