Fix review follow-ups and run the quickstart notebook in CI - #5
Conversation
Corrects the items left open by the 2026-08-23 source review. Behavior: - bootstrap restores the Spark checkpoint directory the caller had configured. setCheckpointDir appends a UUID and getCheckpointDir reports the combined path, so a naive save-and-restore deepens the path once per call; _checkpoint_root strips the UUID instead. - The local checkpoint fallback is scoped to the current user. A fixed /tmp/replicas belongs to whoever creates it first. - box_plot raises a ValueError naming the required replica column instead of a bare pandas KeyError from melt. - plot_pr rejects a ci outside (0, 1] before percentile_approx sees it. - The replica column is int32 on all three backends. pandas produced int64 and Polars Int64. Documentation: - at() states that its lowest-qualifying-threshold rule suits a metric that does not increase as the threshold falls. A recall target degenerates to the group's minimum threshold. - confusion_table states that the non-null and mutually-exclusive conditions on the indicator columns belong to the caller, and why they are not checked. - The metric functions state the single-partition cost of an empty group_by; bootstrap states the memory cost of an empty by, and points at the README for the NaN exception to cross-backend parity. CI: - A notebook job runs scripts/check_example_notebook.py against examples/quickstart.ipynb. The script executes the notebook and compares every output with the committed one, so a code change that breaks the documented workflow, or silently moves its numbers, turns the job red. Verified against both a changed expectation and an injected defect in the Spark metric backend. Nine new tests; the suite is at 104 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review of the first checkpoint fix. Spark has no per-call checkpoint path: writing to a chosen directory means mutating session-wide SparkContext state. Two concurrent bootstrap() calls could interleave the read, set, checkpoint, and restore, so one call checkpointed into the other's directory or restored the wrong value. _checkpoint now holds a process-wide lock for the whole sequence. A SparkContext is a per-process singleton, so the lock covers every caller that can reach the state. A failed restore no longer passes silently. It leaves session-wide state behind, so it raises a RuntimeWarning naming the directory. It does not raise: the bootstrap has already succeeded by that point, and discarding a completed job to report a state-cleanup failure is worse than reporting it. Restoration no longer parses Spark's generated path. setCheckpointDir appends a UUID and getCheckpointDir reports the combined path, so a plain save-and-restore deepens the path once per call; the previous fix stripped the UUID with a regex over an internal path format. The backend now remembers the root it set alongside the value Spark reported for it. A directory this package never set has no known root, so the first restore of one still costs a single level; every call after it reuses the remembered root and the depth holds there. Four new tests, all on fakes so they are fast and deterministic: non-interleaving under two threads (fails with an interleaved event log when the lock is removed), depth stable over 50 calls, the restore warning, and the untouched-state path when the caller supplied the directory. Suite at 108 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — points 1, 2 and 3 were right, and 1. Concurrency — fixedAgreed, and the failure mode is as you describe. Worth stating the constraint plainly first: So the sequence is serialized rather than removed: with _CHECKPOINT_LOCK:
previous = spark_context.getCheckpointDir()
spark_context.setCheckpointDir(checkpoint_dir)
try:
return df.checkpoint(eager=True)
finally:
_restore_checkpoint_dir(spark_context, previous)A Also added: when the caller has already configured a directory and passes no
— 2. Swallowed exceptions — fixedIt now warns, naming the directory and telling the caller what state they are left in: except Exception as error: # noqa: BLE001 - Py4J raises outside one hierarchy
warnings.warn(
"replicas could not restore the Spark checkpoint directory to "
f"{target!r}: {error!r}. ...",
RuntimeWarning,
stacklevel=3,
)It warns rather than raises on purpose: the bootstrap has already succeeded by that point, and discarding a completed job to report a state-cleanup failure is the worse trade. The catch stays broad because Py4J errors do not share one hierarchy — the defect you identified was the silence, not the breadth. 3. Path parsing — removedThe regex is gone. The backend now remembers the root it set alongside the value Spark reported for it, so restoration never reads Spark's path format. One consequence I want to be explicit about, because it is a real trade rather than a clean win: a directory this package never set has no known root, so the first restore of a caller-configured directory still costs one level ( Bounded at +1 and format-independent beats exact and format-dependent, but say the word if you would rather have it exact. 4. Per-user fallback — kept, and separableThis one I would push back on gently. It is not hardening for a hypothetical: It is fully separable from the concurrency work, so if you would rather ship it on its own, it reverts in one commit and I will open it separately. 5. Notebook checker — imagesCorrect, and deliberate. Figure PNG bytes shift with matplotlib and font versions, so byte comparison would be red on unrelated dependency bumps rather than on defects. Worth noting what is covered: A facet that stops rendering, or a grid that changes shape, fails the job. Pixel-level regressions do not. If you want real image diffing, Verification
|
Spark reports a checkpoint directory as <root>/<generated-id>, and setting that value back appends another id. A directory configured outside this package has no remembered root, so restoring it leaves the caller one generated level deeper than they set. It happens at most once: the restore is itself recorded, so later calls reuse the remembered root and the depth holds. The behavior was already covered by a test; this states it as a known limitation in _restore_target, the bootstrap docstring, the README, and the changelog, so a reader meets it before a surprising path does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the re-review. It is stated in four places, so a reader meets it before a surprising path does:
No behavior change in this commit. The PR is ready to merge whenever you are. |
Corrects the items left open by the 2026-08-23 source review, and adds a CI job
that executes
examples/quickstart.ipynb.Behavior
bootstrapused to leave its owndirectory set on the session, which silently redirected every later
checkpoint()the caller made. One detail surfaced here:setCheckpointDirappends a UUID and
getCheckpointDirreports the combined path, so a naivesave-and-restore deepens the path once per call.
_checkpoint_rootstrips theUUID, and the caller's root stays stable across repeated calls.
/tmp/replicasbelongsto whoever creates it first; every other user on the machine then fails to
write into it. The fallback is now
<tempdir>/replicas-<user>.box_plotexplains thereplicacolumn. It always required one. Withoutit, pandas raised a bare
KeyErrorfrommeltthat named neither the reasonnor the fix.
plot_prvalidatesci. A value outside(0, 1]used to fail later,inside
percentile_approx, far from the caller.replicaisint32on all three backends. pandas producedint64andPolars
Int64, so the three schemas disagreed on a column that holds areplica index.
Documentation
at()states that its lowest-qualifying-threshold rule suits a metric thatdoes not increase as the threshold falls, such as
precision. Arecalltarget degenerates to the group's minimum threshold, because recall does not
decrease as the threshold falls. Every current caller passes
precision, sothis was latent.
confusion_tablestates that the non-null and mutually-exclusive conditionson its indicator columns belong to the caller, and why they are not checked:
validating them costs a full pass, which on Spark means an eager job before
the lazy plan the caller asked for.
group_by.bootstrapstates the memory cost of an emptybyon Spark, and points atthe README for the NaN exception to the cross-backend parity guarantee.
CI
A
notebookjob runsscripts/check_example_notebook.py examples/quickstart.ipynb.The script executes the notebook in memory and compares every output with the
committed one, so a change that breaks the documented workflow — or silently
moves its numbers — turns the job red. Memory addresses are normalized and
Spark's
stderrlog lines are ignored; the committed file is never rewritten.I verified both failure paths: a tampered expectation produces a diff, and an
injected defect in the Spark metric backend (
recall = TP / (positives + 1))fails at cell 16. The defect was reverted.
examples/precision_recall.ipynbis deliberately excluded. It is the referencedesign: it defines its own copies of the functions and needs kagglehub,
scikit-learn, and CatBoost.
Not included
An earlier draft of the review reported a crash in
box_plotandplot_prwhenhue,row, orcolis"replica". Both helpers build their distributionacross replicas, so no caller passes that value. The crash is real but
unreachable, and it is not a finding.
Verification
ruff check .—All checks passed!;ruff format --check .— 30 files already formatted.pytest -q—104 passed(95 before, 9 new tests).scripts/check_example_notebook.py examples/quickstart.ipynb— clean, matches committed outputs, ~35s.🤖 Generated with Claude Code