Skip to content
Open
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
2 changes: 1 addition & 1 deletion predicators/approaches/gnn_approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def learn_from_offline_dataset(self, dataset: Dataset) -> None:
self._add_output_specific_fields_to_save_info(info)
save_path = utils.get_approach_save_path_str()
with open(f"{save_path}_None.gnn", "wb") as f:
utils.pkl_dump_with_retry(info, f)
utils.pkl_dump_all_or_nothing(info, f)

def load(self, online_learning_cycle: Optional[int]) -> None:
save_path = utils.get_approach_load_path_str()
Expand Down
2 changes: 1 addition & 1 deletion predicators/approaches/nsrt_learning_approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def _learn_nsrts(self, trajectories: List[LowLevelTrajectory],
annotations=annotations)
save_path = utils.get_approach_save_path_str()
with open(f"{save_path}_{online_learning_cycle}.NSRTs", "wb") as f:
utils.pkl_dump_with_retry(self._nsrts, f)
utils.pkl_dump_all_or_nothing(self._nsrts, f)
if CFG.compute_sidelining_objective_value:
self._compute_sidelining_objective_value(trajectories)

Expand Down
55 changes: 22 additions & 33 deletions predicators/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3744,40 +3744,29 @@ def save_ground_atom_dataset(ground_atom_dataset: List[GroundAtomTrajectory],
pkl.dump(ground_atom_dataset_to_pkl, f)


def pkl_dump_with_retry(obj: Any, f: IO[bytes]) -> None:
"""``pkl.dump``, retried once after a collection if it raises TypeError.

Saving a learned artifact intermittently dies with ``TypeError: cannot
pickle '_abc._abc_data' object``, which is the C-level cache behind an
abstract base class. On CI it is reproducible for a given set of tests --
the same failures twice on the same shard, three times over -- and it has
been seen from two call sites, ``nsrt_learning_approach._learn_nsrts`` and
``gnn_approach.learn_from_offline_dataset``. Locally it appears at roughly
one run in four with the code, test order and PYTHONHASHSEED all fixed.

The root cause is NOT established. What is: an ``_abc_data`` holds WEAK
references, so whether dill trips over one plausibly depends on collection
timing, which is the one thing that varies run to run under everything
else being pinned. ``gc.collect()`` before retrying is aimed at exactly
that. **This is a mitigation on a hypothesis, not a fix on a diagnosis** --
if it stops the failures it is also the evidence for the hypothesis, and
if it does not, that rules the hypothesis out.

Serialising to bytes first rather than retrying into ``f`` matters: a dump
that raises part-way has already written a prefix, and a retry appending
to that would leave a corrupt file that only fails at load time, which is
much worse than the error being fixed here.

Any TypeError is retried, not only the ``_abc_data`` one. Matching on the
message would break silently when it is reworded, and an object that is
genuinely unpicklable fails the second time too and raises as it always
would -- so the broad catch costs one wasted attempt and hides nothing.
def pkl_dump_all_or_nothing(obj: Any, f: IO[bytes]) -> None:
"""``pkl.dump``, but serialise fully before writing a single byte.

A dump that raises part-way has already written a prefix, and that
prefix is a pickle that only fails at LOAD time -- long after the
run that produced it could have been repeated. Building the whole
blob first means a failure leaves the file empty and raises where
the artifact was produced.

This used to carry a ``gc.collect()`` retry aimed at the
intermittent ``TypeError: cannot pickle '_abc._abc_data' object``
seen when saving learned NSRTs and GNN weights. That was a guess at
collection timing and it did not stop the failures. The cause was
the ``dill==0.3.5.1`` pin: dill serialises a class *by value* when
it cannot find it again at ``module.qualname``, and before 0.3.6 the
by-value path shipped the class ``__dict__`` verbatim -- including
the ``_abc_impl`` slot that ``ABCMeta`` puts on every class it
builds, which is an unpicklable ``_abc_data``. dill 0.3.6 added
``_get_typedict_abc``, which strips ``_abc_impl`` and the ABC
registry caches and re-registers the subclasses on load. The pin is
now 0.3.9, so the retry has nothing left to retry.
"""
try:
blob = pkl.dumps(obj)
except TypeError:
gc.collect()
blob = pkl.dumps(obj)
blob = pkl.dumps(obj)
f.write(blob)


Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"torchvision>=0.17.0",
"scipy==1.9.3",
"tabulate==0.9.0",
"dill==0.3.5.1",
"dill==0.3.9",
"pyperplan",
"pathos",
"pillow==10.3.0",
Expand Down
42 changes: 31 additions & 11 deletions tests/code_sim_learning/test_param_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,18 +317,38 @@ def simulator_fn(state, _action, params):
logger.info(" %s: fitted=%.4f, true=%.4f, rel_err=%.1f%%", name, val,
true_val, rel_err * 100)

# happiness_speed is excluded from the strict assertion. Its rule is
# gated by ``filled_w`` so only transitions with a near-filled jug
# carry information about it — and PyBullet trajectory generation is
# platform-dependent (macOS vs Linux differ enough that the chain
# stays near init on CI even when it moves locally). The fitted
# value is still logged above for visibility.
# These assertions read the POSTERIOR, not ``point_estimate``.
# ``point_estimate`` is the single highest-log-probability draw out
# of 9600, and this data leaves the rate params only weakly
# identified: the 95% credible interval for water_fill_speed spans
# roughly +-70% of its true value. Which draw wins the argmax
# therefore moves a lot between machines, and PyBullet trajectory
# generation is platform-dependent on top of that, so the
# transitions feeding the chain differ too. Asserting a 30%
# tolerance on that one draw failed on CI runners while passing
# locally, on the same commit. Percentiles of the same chain are
# stable, so they are what gets checked: the truth has to sit inside
# the interval, and the median has to close most of the gap the 50%
# perturbation opened.
#
# happiness_speed stays unasserted. Its rule is gated by
# ``filled_w``, so only transitions with a near-filled jug carry any
# information about it, and a rollout can end up with too few of
# those to move the chain at all. It is logged above for visibility.
for name in ["water_fill_speed", "heating_speed"]:
true_val = GT_PARAMS[name]
fitted_val = fitted[name]
rel_err = abs(fitted_val - true_val) / true_val
assert rel_err < 0.3, (
f"{name}: fitted={fitted_val:.4f}, true={true_val:.4f}, "
f"rel_err={rel_err:.1%}")
init_val = true_val * 0.5
col = result.samples[:, result.names.index(name)]
lo, hi = np.percentile(col, [2.5, 97.5])
assert lo <= true_val <= hi, (
f"{name}: true={true_val:.4f} is outside the 95% credible "
f"interval [{lo:.4f}, {hi:.4f}]")
median = float(np.median(col))
init_err = abs(init_val - true_val)
median_err = abs(median - true_val)
assert median_err <= 0.75 * init_err, (
f"{name}: posterior median={median:.4f} (true={true_val:.4f}) "
f"closed only {1 - median_err / init_err:.0%} of the gap from "
f"init={init_val:.4f}")

logger.info("All rate parameter recovery checks passed.")
64 changes: 37 additions & 27 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test cases for utils."""
import abc
import os
import time
from typing import Iterator, Optional, Tuple
Expand Down Expand Up @@ -3748,43 +3749,52 @@ def test_parse_model_output_into_option_plan_strict():
assert no_seed[0][2] == []


def test_pkl_dump_with_retry_survives_a_transient_failure(
tmp_path, monkeypatch):
"""A TypeError on the first attempt must not lose the artifact.
def test_pkl_dump_all_or_nothing_round_trips_a_by_value_abc(tmp_path):
"""An ABC pickled BY VALUE must survive the round trip.

The real failure is ``cannot pickle '_abc._abc_data' object``, seen
intermittently while saving learned NSRTs and GNN weights. It is
faked here because it does not reproduce on demand -- which is the
whole reason the retry exists rather than a targeted fix.
This is the regression test for ``TypeError: cannot pickle
'_abc._abc_data' object``, which used to fail runs intermittently
while saving learned NSRTs and GNN weights. dill serialises a class
by value whenever it cannot find that exact class again at
``module.qualname`` -- as here, where the class is local to this
function -- and before dill 0.3.6 the by-value path shipped the
class ``__dict__`` verbatim, ``_abc_impl`` included. ``_abc_impl``
is the unpicklable ``_abc_data`` that ``ABCMeta`` puts on every
class it builds, so the dump died. If the ``dill`` pin ever slips
back below 0.3.6, this fails here instead of at random in CI.
"""
attempts = []
real_dumps = utils.pkl.dumps

def _flaky_dumps(obj, *args, **kwargs):
"""Fail once with the real error, then behave."""
attempts.append(obj)
if len(attempts) == 1:
raise TypeError("cannot pickle '_abc._abc_data' object")
return real_dumps(obj, *args, **kwargs)
class Base(abc.ABC):
"""A local ABC, so dill cannot pickle it by reference."""

@abc.abstractmethod
def value(self) -> int:
"""The subclass's answer."""

class Impl(Base):
"""A concrete subclass, carrying an ``_abc_impl`` of its own."""

monkeypatch.setattr(utils.pkl, "dumps", _flaky_dumps)
def value(self) -> int:
"""The answer."""
return 3

assert "_abc_impl" in Impl.__dict__, "no ABCMeta cache to trip over"
path = tmp_path / "artifact.pkl"
with open(path, "wb") as f:
utils.pkl_dump_with_retry({"learned": [1, 2, 3]}, f)

assert len(attempts) == 2, "the failed dump was not retried"
utils.pkl_dump_all_or_nothing({"cls": Impl}, f)
with open(path, "rb") as f:
assert utils.pkl.load(f) == {"learned": [1, 2, 3]}
loaded = utils.pkl.load(f)
assert loaded["cls"]().value() == 3


def test_pkl_dump_with_retry_writes_nothing_when_it_fails(
def test_pkl_dump_all_or_nothing_writes_nothing_when_it_fails(
tmp_path, monkeypatch):
"""A persistent failure must raise and leave the file EMPTY.
"""A failed dump must raise and leave the file EMPTY.

Retrying into the file handle would append to the prefix the failed
dump already wrote, and a half-written pickle only fails at LOAD
time -- long after the run that produced it could have been
repeated.
Dumping straight into the file handle would leave the prefix the
failed dump had already written, and a half-written pickle only
fails at LOAD time -- long after the run that produced it could have
been repeated.
"""

def _always_fails(obj, *args, **kwargs):
Expand All @@ -3796,7 +3806,7 @@ def _always_fails(obj, *args, **kwargs):
path = tmp_path / "artifact.pkl"
with pytest.raises(TypeError) as excinfo:
with open(path, "wb") as f:
utils.pkl_dump_with_retry({"learned": [1, 2, 3]}, f)
utils.pkl_dump_all_or_nothing({"learned": [1, 2, 3]}, f)
assert "_abc_data" in str(excinfo.value), \
"a genuinely unpicklable object must still report why"
assert path.stat().st_size == 0, "a failed dump left a partial file"
Loading