From 275f216506e90cf4bb166078767f6dbe1b350546 Mon Sep 17 00:00:00 2001 From: helloiamvu Date: Wed, 15 Jul 2026 10:31:08 +0200 Subject: [PATCH 01/13] fix(align): ContractError instead of raw MergeError for incomparable as-of knowledge columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README-validation find: a BYO spine WITHOUT the obs source's equality-join key ('date') falls to the as-of path, where merge_asof crashed with a bare pandas MergeError (ISO-string 'date' vs datetime decision_time). Per the error-copy contract, _gate_asof_knowledge_dtype now raises a ContractError naming the source, the column + dtypes, and the concrete fix — with a distinct message for each shape: missing equality key (add 'date' to the spine / use weather.days() or mr.spine()), non-datetime knowledge column, and tz-naive vs tz-aware mixing. The gate only raises where merge_asof would raise anyway — no previously-working input changes behavior (pinned parity gate 5/5 byte-green). Co-Authored-By: Claude Fable 5 --- packages/core/src/mostlyright/align.py | 69 ++++++++++++++++++++++++++ packages/core/tests/test_align.py | 34 +++++++++++++ packages/core/tests/test_align_lazy.py | 33 ++++++++++++ 3 files changed, 136 insertions(+) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index c2180ac7..e9dd8931 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -467,6 +467,7 @@ def align( if equality: result = _equality_join(result, prefixed, spec) else: + _gate_asof_knowledge_dtype(prefixed, spec, kt_col, result) result = _asof_join(result, prefixed, spec, kt_col) # pit_fidelity="reconstructed" sources are knowingly not point-in-time # (highest-authority-available, e.g. the CLI settlement label) → exempt. @@ -489,6 +490,74 @@ def align( return result +def _gate_asof_knowledge_dtype( + prefixed: pd.DataFrame, spec: SourceContract, kt_col: str | None, spine: pd.DataFrame +) -> None: + """Refuse an as-of join whose knowledge column cannot be compared to + ``decision_time`` — BEFORE pandas raises a bare ``MergeError``. + + The trap this guards (README-validation find): a BYO spine WITHOUT the + source's equality-join key (``spec.event_time_col``, e.g. the obs source's + ``date``) silently falls to the as-of path, where the source's ISO-string + knowledge column cannot be compared to the datetime ``decision_time`` — + the user got ``pandas.errors.MergeError`` instead of error copy naming the + source, the column, and the fix. Also catches the tz-naive-vs-aware + variant of the same crash. Only ever raises where ``merge_asof`` would + raise anyway — a previously-working input never changes behavior. + """ + if not kt_col or kt_col not in prefixed.columns or _DECISION_COL not in spine.columns: + return + kdt = prefixed[kt_col].dtype + ddt = spine[_DECISION_COL].dtype + k_is_dt = pd.api.types.is_datetime64_any_dtype(kdt) + if k_is_dt and (getattr(kdt, "tz", None) is None) == (getattr(ddt, "tz", None) is None): + return + + from mostlyright.core.exceptions import ContractError + + if spec.event_time_col and spec.event_time_col not in spine.columns: + problem = ( + f"the spine is missing this source's equality-join key " + f"{spec.event_time_col!r}, so align fell back to an as-of join — but the " + f"source's knowledge column {spec.knowledge_time_col!r} (dtype {kdt}) " + f"cannot be as-of-compared to the spine {_DECISION_COL!r} (dtype {ddt})" + ) + fix = ( + f"add the {spec.event_time_col!r} column (the ISO settlement day) to your " + f"spine — weather.days() and the label factories carry it, and mr.spine() " + f"can map it from your own frame" + ) + elif not k_is_dt: + problem = ( + f"knowledge_time_col {spec.knowledge_time_col!r} has dtype {kdt}, which " + f"cannot be as-of-compared to the spine {_DECISION_COL!r} (dtype {ddt})" + ) + fix = ( + f"convert the source's {spec.knowledge_time_col!r} column to a " + f"timezone-aware datetime (e.g. pd.to_datetime(..., utc=True))" + ) + else: + problem = ( + f"knowledge_time_col {spec.knowledge_time_col!r} (dtype {kdt}) and the " + f"spine {_DECISION_COL!r} (dtype {ddt}) mix timezone-naive and " + f"timezone-aware timestamps" + ) + fix = ( + f"make both timezone-aware (e.g. pd.to_datetime(..., utc=True) on the " + f"source's {spec.knowledge_time_col!r})" + ) + raise ContractError( + contract_message( + source_id=spec.id, + field=spec.knowledge_time_col, + problem=problem, + fix=fix, + ), + field=spec.knowledge_time_col, + source=spec.id, + ) + + def _free_internal_name(base: str, *frames: pd.DataFrame) -> str: """Return ``base`` (or ``base_N``) guaranteed absent from every frame. diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index ef568cbd..a083b80c 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -234,3 +234,37 @@ def test_align_missing_declared_column_raises_contract_error() -> None: align(spine, (frame, spec)) assert "temp_f" in str(exc_info.value) + + +def test_asof_naive_knowledge_time_vs_aware_decision_raises_contract_error() -> None: + """Error-copy regression: a tz-NAIVE knowledge column as-of-joined against + the tz-aware ``decision_time`` previously crashed with a raw pandas + ``MergeError``. It must raise a :class:`ContractError` naming the source + and the tz mismatch.""" + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + { + "station": ["A"], + "known_at": pd.to_datetime(["2025-01-01T00:00:00"]), # NAIVE + "temp_f": [1.0], + } + ) + spine = pd.DataFrame( + { + "station": ["A"], + "decision_time": pd.to_datetime(["2025-01-05T00:00:00Z"], utc=True), + } + ) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + assert "met" in str(exc_info.value) diff --git a/packages/core/tests/test_align_lazy.py b/packages/core/tests/test_align_lazy.py index 0b0f9b69..52977d49 100644 --- a/packages/core/tests/test_align_lazy.py +++ b/packages/core/tests/test_align_lazy.py @@ -287,3 +287,36 @@ def test_lazy_obs_features_actually_attach_to_days_spine(monkeypatch) -> None: assert "obs_high_f" in out.columns assert out["obs_high_f"].notna().all() + + +def test_byo_spine_missing_source_event_key_raises_contract_error(monkeypatch) -> None: + """Error-copy regression (README validation find): a BYO spine WITHOUT the + obs source's equality-join key (``date``) previously crashed inside + pandas ``merge_asof`` with a raw ``MergeError`` (string ``date`` vs + datetime ``decision_time``). It must raise a style-guide + :class:`ContractError` naming the source, the column, and a concrete fix.""" + import importlib + + obs_mod = importlib.import_module("mostlyright.weather.obs") + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [{"observed_at": "2025-01-06T18:00:00Z", "temp_f": 40.0}], + ) + import mostlyright as mr + + my_label_frame = pd.DataFrame( + { + "station": ["NYC"], + "cutoff": _ts(["2025-01-07T21:30:00Z"]), + "settle_high": [41.0], + } + ) + spine = mr.spine(my_label_frame, entity="station", decision_time="cutoff", y="settle_high") + + with pytest.raises(ContractError) as exc_info: + mr.align(spine, obs_mod.obs("NYC")) + + msg = str(exc_info.value) + assert "date" in msg + assert "spine" in msg.lower() From 8cecd3d50e035b24e5ad8287d9f23f29c5879695 Mon Sep 17 00:00:00 2001 From: helloiamvu Date: Wed, 15 Jul 2026 10:31:48 +0200 Subject: [PATCH 02/13] fix(shims): research shim survives import-system parent binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped-1.17.0 regression found via README validation: the __getattr__ scrub only runs when a SHIM ACCESS performs the first import of the mostlyright.research submodule. When anything else imports it first (the eager obs() path does 'from mostlyright.research import _resolve_station'), the import system auto-binds the SUBMODULE as the package attribute with no scrub — mostlyright.research(...) then raised TypeError: 'module' object is not callable for the rest of the process (fresh-process repro: eager weather.obs() then mostlyright.research()). Fix: the package's module class now carries a data-descriptor 'research' property (data descriptors outrank the instance __dict__), so the shim stays authoritative regardless of import order; the property setter no-ops the import system's auto-binding. Warning semantics unchanged (once-per-session via _LEGACY_SHIM_WARNED). Regression tests: in-process parent-binding simulation + fresh-subprocess end-to-end repro. Co-Authored-By: Claude Fable 5 --- packages/core/src/mostlyright/__init__.py | 35 +++++++++++++++ packages/core/tests/test_shims.py | 53 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index e84cb41b..e1f7589d 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -34,6 +34,8 @@ # Split-distribution namespace: extend __path__ to discover sibling packages' contributions. __path__ = __import__("pkgutil").extend_path(__path__, __name__) +import sys as _sys +import types as _types import warnings from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _dist_version @@ -194,3 +196,36 @@ def __getattr__(name: str): return obj raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# --------------------------------------------------------------------------- +# Import-system-proof shim for the ``research`` name collision (post-1.17.0). +# +# The ``__getattr__`` scrub above only runs when a SHIM ACCESS performed the +# first import of the submodule. When something else imports it first — e.g. +# the eager ``obs()`` path's ``from mostlyright.research import +# _resolve_station`` — the import system auto-binds the SUBMODULE as this +# package's ``research`` attribute with no scrub, shadowing the shim: the next +# ``mostlyright.research(...)`` raised ``TypeError: 'module' object is not +# callable`` for the rest of the process. A data descriptor on the package's +# own module class outranks the instance ``__dict__``, so the shim stays +# authoritative no matter who imports first. The setter is a deliberate no-op +# that drops the import system's auto-binding — the submodule stays reachable +# via ``sys.modules`` / ``import mostlyright.research``, which is how all +# internal code addresses it. +# --------------------------------------------------------------------------- + + +class _MostlyrightModule(_types.ModuleType): + @property + def research(self): + _warn_legacy_shim("research") + return _resolve_legacy_shim("research") + + @research.setter + def research(self, _value) -> None: + # Only writer: the import system binding the submodule post-import. + return + + +_sys.modules[__name__].__class__ = _MostlyrightModule diff --git a/packages/core/tests/test_shims.py b/packages/core/tests/test_shims.py index 6a5cc778..a3e04c60 100644 --- a/packages/core/tests/test_shims.py +++ b/packages/core/tests/test_shims.py @@ -17,6 +17,7 @@ from __future__ import annotations +import contextlib import warnings import pytest @@ -184,3 +185,55 @@ def test_d22_bridge_warning_constant_removed() -> None: research_mod = importlib.import_module("mostlyright.research") assert not hasattr(research_mod, "_D22_BRIDGE_WARNING") + + +def test_research_shim_survives_import_system_parent_binding() -> None: + """Post-1.17.0 regression (README-validation find): the import system binds + the ``mostlyright.research`` SUBMODULE onto the package when anything does + ``from mostlyright.research import ...`` FIRST (the eager ``obs()`` path + does exactly that internally) — which previously shadowed the ``research`` + shim and made ``mostlyright.research(...)`` raise + ``TypeError: 'module' object is not callable`` for the rest of the process. + The shim must stay authoritative over the auto-bound submodule attribute.""" + import importlib + import sys + + import mostlyright + + research_mod = importlib.import_module("mostlyright.research") + # Simulate the import system's parent binding (what a genuine FIRST + # ``from mostlyright.research import _x`` does in a fresh process). The + # fix may refuse the binding outright — equally acceptable. + with contextlib.suppress(AttributeError): + mostlyright.research = research_mod + assert callable(mostlyright.research), ( + "mostlyright.research must remain the callable legacy shim even after " + "the research SUBMODULE is auto-bound onto the package by the import " + "system" + ) + assert mostlyright.research is sys.modules["mostlyright.research"].research + + +def test_research_shim_callable_in_fresh_process_after_eager_obs() -> None: + """End-to-end fresh-process repro: eager ``weather.obs()`` (whose internals + import ``mostlyright.research`` first) must not break the legacy + ``mostlyright.research(...)`` entry point.""" + import subprocess + import sys + + code = ( + "import warnings; warnings.simplefilter('ignore')\n" + "from unittest.mock import patch\n" + "import importlib\n" + "import mostlyright\n" + "obs_mod = importlib.import_module('mostlyright.weather.obs')\n" + "with patch.object(obs_mod, '_dispatch_strategy',\n" + " lambda *a, **k: [{'observed_at': '2025-01-06T18:00:00Z'," + " 'temp_f': 40.0}]):\n" + " obs_mod.obs('KNYC', '2025-01-06', '2025-01-07')\n" + "assert callable(mostlyright.research), type(mostlyright.research)\n" + "print('SHIM-OK')\n" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120) + assert proc.returncode == 0, proc.stderr + assert "SHIM-OK" in proc.stdout From 271f166a0e4caea05f80e69e903f463d786b5b66 Mon Sep 17 00:00:00 2001 From: helloiamvu Date: Wed, 15 Jul 2026 10:52:34 +0200 Subject: [PATCH 03/13] fix(review-iter-1): gate mirrors merge_asof exactly; root research symbol stays patchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer findings on 275f216/8cecd3d (codex 1 CRITICAL + 2 HIGH; Python Architect 1 HIGH — the Architect independently verified the working-tree predicate rewrite against a live merge_asof dtype matrix): - CRITICAL(codex): the gate required datetime keys, rejecting numeric as-of joins merge_asof supports (BYO int64 decision_time spine worked pre-gate). The predicate is now EXACT-DTYPE-EQUALITY within an as-of-orderable family (numeric | datetime | timedelta) — empirically probed: int64/int64 OK, int64/float64 raises, ns/us raises, UTC/Eastern raises, equal-object raises. Only raises where pandas raises. - HIGH(codex): resolution/timezone mismatches between aware datetimes fell through to raw MergeError — now gated with copy naming both dtypes. - HIGH(codex, accepted trade-off): the property masks 'import mostlyright.research as rm' module semantics — documented in-code: that form appears nowhere in repo/docs; sys.modules/importlib/from-import/ mock.patch dotted targets all preserved (pinned by test); the package attribute has been reserved for the legacy callable since 34-06 (whose byte-identity tests rule out a callable-module design). - HIGH(architect): the unconditional no-op setter swallowed INTENTIONAL writes — monkeypatch.setattr(mostlyright, 'research', stub) silently no-oped and mock.patch teardown crashed on the missing deleter. The setter now stores every write; the getter treats exactly ONE value as non-authoritative (the auto-bound submodule object); a deleter restores cleanly. Regression test covers monkeypatch + mock.patch + del. Fast suite 4625 passed; pinned parity 5/5. Co-Authored-By: Claude Fable 5 --- packages/core/src/mostlyright/__init__.py | 32 ++++++++-- packages/core/src/mostlyright/align.py | 39 ++++++++---- packages/core/tests/test_align.py | 77 +++++++++++++++++++++++ packages/core/tests/test_shims.py | 47 ++++++++++++++ 4 files changed, 178 insertions(+), 17 deletions(-) diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index e1f7589d..9f591af4 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -211,21 +211,43 @@ def __getattr__(name: str): # own module class outranks the instance ``__dict__``, so the shim stays # authoritative no matter who imports first. The setter is a deliberate no-op # that drops the import system's auto-binding — the submodule stays reachable -# via ``sys.modules`` / ``import mostlyright.research``, which is how all -# internal code addresses it. +# via ``sys.modules`` / ``importlib`` / ``from mostlyright.research import x`` +# / ``mock.patch`` dotted targets (all resolve through the import system, not +# this attribute), which is how every internal and test call site addresses it +# (pinned by test_research_submodule_access_paths_survive_the_property_shim). +# +# KNOWN, ACCEPTED trade-off: ``import mostlyright.research as rm`` binds ``rm`` +# via THIS attribute and therefore yields the legacy callable, not the module. +# That form appears nowhere in the repo, docs, or published examples; the +# package attribute has been contractually reserved for the legacy callable +# since the 34-06 namespace inversion (whose byte-identity tests pin +# ``mostlyright.research is mostlyright.weather.research``, ruling out a +# callable-module design). Both names retire at 2.0. # --------------------------------------------------------------------------- class _MostlyrightModule(_types.ModuleType): @property def research(self): + # An INTENTIONAL write (monkeypatch.setattr / mock.patch in user test + # suites) is honored verbatim. Exactly ONE value is treated as + # non-authoritative and falls through to the shim: the auto-bound + # submodule object itself — the only thing the import system ever + # writes here (Python-Architect review: an unconditional no-op setter + # silently broke root-symbol patching and crashed mock teardown). + override = self.__dict__.get("research") + if override is not None and override is not _sys.modules.get("mostlyright.research"): + return override _warn_legacy_shim("research") return _resolve_legacy_shim("research") @research.setter - def research(self, _value) -> None: - # Only writer: the import system binding the submodule post-import. - return + def research(self, value) -> None: + self.__dict__["research"] = value + + @research.deleter + def research(self) -> None: + self.__dict__.pop("research", None) _sys.modules[__name__].__class__ = _MostlyrightModule diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index e9dd8931..d058bffe 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -501,16 +501,28 @@ def _gate_asof_knowledge_dtype( ``date``) silently falls to the as-of path, where the source's ISO-string knowledge column cannot be compared to the datetime ``decision_time`` — the user got ``pandas.errors.MergeError`` instead of error copy naming the - source, the column, and the fix. Also catches the tz-naive-vs-aware - variant of the same crash. Only ever raises where ``merge_asof`` would - raise anyway — a previously-working input never changes behavior. + source, the column, and the fix. + + The compatibility predicate MIRRORS ``merge_asof`` exactly (empirically + probed on pandas 2.x, pinned by tests): the two key dtypes must be + IDENTICAL (including datetime resolution and timezone — ``int64/float64``, + ``ns/us``, ``UTC/US-Eastern`` and naive/aware pairs all raise in pandas) + and belong to an as-of-orderable family (numeric, datetime, or timedelta — + equal ``object`` dtypes also raise in pandas). The gate therefore only + ever raises where ``merge_asof`` would raise anyway — a previously-working + input (e.g. a BYO spine as-of-joining on int64 keys) never changes + behavior. """ if not kt_col or kt_col not in prefixed.columns or _DECISION_COL not in spine.columns: return kdt = prefixed[kt_col].dtype ddt = spine[_DECISION_COL].dtype - k_is_dt = pd.api.types.is_datetime64_any_dtype(kdt) - if k_is_dt and (getattr(kdt, "tz", None) is None) == (getattr(ddt, "tz", None) is None): + orderable = ( + pd.api.types.is_numeric_dtype(kdt) + or pd.api.types.is_datetime64_any_dtype(kdt) + or pd.api.types.is_timedelta64_dtype(kdt) + ) + if kdt == ddt and orderable: return from mostlyright.core.exceptions import ContractError @@ -527,10 +539,11 @@ def _gate_asof_knowledge_dtype( f"spine — weather.days() and the label factories carry it, and mr.spine() " f"can map it from your own frame" ) - elif not k_is_dt: + elif not orderable: problem = ( - f"knowledge_time_col {spec.knowledge_time_col!r} has dtype {kdt}, which " - f"cannot be as-of-compared to the spine {_DECISION_COL!r} (dtype {ddt})" + f"knowledge_time_col {spec.knowledge_time_col!r} has dtype {kdt}, which is " + f"not an as-of-orderable key (numeric, datetime, or timedelta) and cannot " + f"be compared to the spine {_DECISION_COL!r} (dtype {ddt})" ) fix = ( f"convert the source's {spec.knowledge_time_col!r} column to a " @@ -539,12 +552,14 @@ def _gate_asof_knowledge_dtype( else: problem = ( f"knowledge_time_col {spec.knowledge_time_col!r} (dtype {kdt}) and the " - f"spine {_DECISION_COL!r} (dtype {ddt}) mix timezone-naive and " - f"timezone-aware timestamps" + f"spine {_DECISION_COL!r} (dtype {ddt}) are not the identical dtype — " + f"merge_asof requires exact dtype equality (timezone, resolution, and " + f"numeric width included)" ) fix = ( - f"make both timezone-aware (e.g. pd.to_datetime(..., utc=True) on the " - f"source's {spec.knowledge_time_col!r})" + f"convert the source's {spec.knowledge_time_col!r} column to the spine's " + f"{_DECISION_COL!r} dtype (e.g. " + f'pd.to_datetime(..., utc=True).astype("{ddt}"))' ) raise ContractError( contract_message( diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index a083b80c..ec827556 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -268,3 +268,80 @@ def test_asof_naive_knowledge_time_vs_aware_decision_raises_contract_error() -> align(spine, (frame, spec)) assert "met" in str(exc_info.value) + + +def test_asof_numeric_keys_still_join_without_gate() -> None: + """Codex review-iter-1 CRITICAL guard: ``merge_asof`` legitimately supports + NUMERIC keys — a BYO spine as-of-joining on identical int64 keys must keep + working (the dtype gate mirrors pandas exactly, it does not require + datetimes).""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + frame = pd.DataFrame({"station": ["A"], "seq": pd.array([1], dtype="int64"), "temp_f": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": pd.array([5], dtype="int64")}) + + out = align(spine, (frame, spec)) + + assert out.loc[0, "met_temp_f"] == 1.0 + + +def test_asof_equal_object_dtypes_raise_contract_error() -> None: + """Equal dtypes are not enough — equal ``object`` keys also raise in + pandas, so the gate must catch them with error copy (not a raw + MergeError).""" + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="day", + pit_fidelity="exact", + ) + frame = pd.DataFrame({"station": ["A"], "day": ["2025-01-01"], "temp_f": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": ["2025-01-05"]}) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + assert "met" in str(exc_info.value) + + +def test_asof_datetime_resolution_mismatch_raises_contract_error() -> None: + """Codex review-iter-1 HIGH guard: both-aware but MIXED-RESOLUTION datetime + keys (``us`` vs ``ns``) raise in pandas — the gate must catch the pair with + error copy naming both dtypes.""" + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + { + "station": ["A"], + "known_at": pd.to_datetime(["2025-01-01T00:00:00Z"], utc=True).as_unit("us"), + "temp_f": [1.0], + } + ) + spine = pd.DataFrame( + { + "station": ["A"], + "decision_time": pd.to_datetime(["2025-01-05T00:00:00Z"], utc=True), + } + ) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + msg = str(exc_info.value) + assert "met" in msg + assert "dtype" in msg diff --git a/packages/core/tests/test_shims.py b/packages/core/tests/test_shims.py index a3e04c60..829f365f 100644 --- a/packages/core/tests/test_shims.py +++ b/packages/core/tests/test_shims.py @@ -237,3 +237,50 @@ def test_research_shim_callable_in_fresh_process_after_eager_obs() -> None: proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120) assert proc.returncode == 0, proc.stderr assert "SHIM-OK" in proc.stdout + + +def test_research_submodule_access_paths_survive_the_property_shim() -> None: + """The package-attribute ``mostlyright.research`` is RESERVED for the + legacy callable (34-06 contract; the module-class property makes that + order-independent). Every supported way of addressing the SUBMODULE keeps + working: sys.modules, importlib, from-import, and mock.patch dotted + targets (which resolve via importlib, not the parent attribute).""" + import importlib + import sys + from unittest.mock import patch + + mod = importlib.import_module("mostlyright.research") + assert sys.modules["mostlyright.research"] is mod + from mostlyright.research import _resolve_station + + assert callable(_resolve_station) + with patch("mostlyright.research._resolve_station", lambda *a, **k: None): + assert mod._resolve_station("KNYC") is None + + +def test_root_research_symbol_remains_patchable() -> None: + """Python-Architect review (iter-1 HIGH): user test suites patch the root + ``mostlyright.research`` symbol — ``monkeypatch.setattr`` and every + ``mock.patch`` form must take effect AND restore cleanly (the first fix's + unconditional no-op setter silently dropped the stub and crashed mock's + ``delattr`` teardown). Only the import system's auto-bound SUBMODULE value + is treated as non-authoritative.""" + from unittest.mock import MagicMock, patch + + import mostlyright + + original = mostlyright.research + assert callable(original) + + sentinel = MagicMock(name="research_stub") + mostlyright.research = sentinel + try: + assert mostlyright.research is sentinel # intentional write honored + finally: + del mostlyright.research + assert callable(mostlyright.research) + assert mostlyright.research is original + + with patch("mostlyright.research") as mocked: + assert mostlyright.research is mocked + assert mostlyright.research is original # teardown restored, no AttributeError From c1c707545d8c569264256e87be76c381b5f12e51 Mon Sep 17 00:00:00 2001 From: helloiamvu Date: Wed, 15 Jul 2026 11:08:16 +0200 Subject: [PATCH 04/13] fix(review-iter-2): drop the stale research scrub that silently un-patched user overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python Architect iter-2 (verified live): globals().pop('research', None) in __getattr__'s legacy branch — originally the auto-binding scrub — became dead weight once the data-descriptor property neutralized the auto-bound submodule, so the ONLY thing it could still delete was an INTENTIONAL user override: an active mock.patch('mostlyright.research') was silently un-patched the moment the code under test touched ANY other legacy symbol (dataset/Station/CATALOG/live), reverting to the real function mid-test. The pop is removed (the live scrub stays — live has no property); test_root_research_symbol_remains_patchable now touches CATALOG + dataset while the mock is active and asserts the stub survives. Fast suite 4625 passed; pinned parity 5/5. Co-Authored-By: Claude Fable 5 --- packages/core/src/mostlyright/__init__.py | 19 ++++++++++--------- packages/core/tests/test_shims.py | 6 ++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index 9f591af4..66688556 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -183,15 +183,16 @@ def __getattr__(name: str): if name in _LEGACY_SHIM_HOMES: _warn_legacy_shim(name) obj = _resolve_legacy_shim(name) - # Resolving a shim imports ``mostlyright.research`` / ``mostlyright.live``, - # whose import machinery auto-binds them as package attributes here — which - # would shadow the ``research``/``live`` shims on the next access (skipping - # the deprecation warning + returning a MODULE for ``research``). Scrub them - # so ``__getattr__`` stays authoritative (once-per-session is governed by - # ``_LEGACY_SHIM_WARNED``, not attribute caching). Internal code reaches the - # submodules via ``sys.modules`` / ``from mostlyright.research import ...``, - # never these package attributes. - globals().pop("research", None) + # Resolving a shim imports ``mostlyright.live``, whose import machinery + # auto-binds it as a package attribute here — which would skip the + # deprecation warning on the next access. Scrub it so ``__getattr__`` + # stays authoritative (once-per-session is governed by + # ``_LEGACY_SHIM_WARNED``, not attribute caching). ``research`` is + # deliberately NOT scrubbed here anymore: its data-descriptor property + # (below) already neutralizes the auto-bound submodule, so a pop here + # could only ever delete an INTENTIONAL user override — silently + # un-patching ``mock.patch("mostlyright.research")`` the moment any + # OTHER legacy symbol was accessed (Python-Architect review-iter-2). globals().pop("live", None) return obj diff --git a/packages/core/tests/test_shims.py b/packages/core/tests/test_shims.py index 829f365f..ccb8f1e0 100644 --- a/packages/core/tests/test_shims.py +++ b/packages/core/tests/test_shims.py @@ -283,4 +283,10 @@ def test_root_research_symbol_remains_patchable() -> None: with patch("mostlyright.research") as mocked: assert mostlyright.research is mocked + # Review-iter-2 (Python Architect): touching ANY other legacy symbol + # used to run the __getattr__ scrub and silently DELETE the active + # patch — the stub must survive unrelated legacy-shim traffic. + _ = mostlyright.CATALOG + _ = mostlyright.dataset + assert mostlyright.research is mocked assert mostlyright.research is original # teardown restored, no AttributeError From 41ce6c732caab75401ab1ac26b1e85a37421bbea Mon Sep 17 00:00:00 2001 From: helloiamvu Date: Wed, 15 Jul 2026 11:37:35 +0200 Subject: [PATCH 05/13] test(align): pin explicit datetime units in the resolution-mismatch test pandas 2 infers ns and pandas 3 infers us from to_datetime, so the implicit spine unit made the mismatch vanish under pd3 (pandas-3-suite DID-NOT-RAISE). Both sides now pin units explicitly; verified under both majors. Co-Authored-By: Claude Fable 5 --- packages/core/tests/test_align.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index ec827556..4a8687d0 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -325,6 +325,9 @@ def test_asof_datetime_resolution_mismatch_raises_contract_error() -> None: knowledge_time_col="known_at", pit_fidelity="exact", ) + # Units pinned EXPLICITLY on both sides: pandas 2 infers ns and pandas 3 + # infers us from to_datetime, so relying on the default would make the + # mismatch vanish under one major (pandas-3-suite caught exactly that). frame = pd.DataFrame( { "station": ["A"], @@ -335,7 +338,7 @@ def test_asof_datetime_resolution_mismatch_raises_contract_error() -> None: spine = pd.DataFrame( { "station": ["A"], - "decision_time": pd.to_datetime(["2025-01-05T00:00:00Z"], utc=True), + "decision_time": pd.to_datetime(["2025-01-05T00:00:00Z"], utc=True).as_unit("ns"), } ) From ea43674b81720ad7a2e2e83a95d1fb5ca24dd06e Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:11:42 +0200 Subject: [PATCH 06/13] fix(shims): preserve explicit None research patches --- packages/core/src/mostlyright/__init__.py | 9 +++++++-- packages/core/tests/test_shims.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index 66688556..21a2bd4c 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -227,6 +227,9 @@ def __getattr__(name: str): # --------------------------------------------------------------------------- +_RESEARCH_OVERRIDE_MISSING = object() + + class _MostlyrightModule(_types.ModuleType): @property def research(self): @@ -236,8 +239,10 @@ def research(self): # submodule object itself — the only thing the import system ever # writes here (Python-Architect review: an unconditional no-op setter # silently broke root-symbol patching and crashed mock teardown). - override = self.__dict__.get("research") - if override is not None and override is not _sys.modules.get("mostlyright.research"): + override = self.__dict__.get("research", _RESEARCH_OVERRIDE_MISSING) + if override is not _RESEARCH_OVERRIDE_MISSING and override is not _sys.modules.get( + "mostlyright.research" + ): return override _warn_legacy_shim("research") return _resolve_legacy_shim("research") diff --git a/packages/core/tests/test_shims.py b/packages/core/tests/test_shims.py index ccb8f1e0..9da4946a 100644 --- a/packages/core/tests/test_shims.py +++ b/packages/core/tests/test_shims.py @@ -290,3 +290,15 @@ def test_root_research_symbol_remains_patchable() -> None: _ = mostlyright.dataset assert mostlyright.research is mocked assert mostlyright.research is original # teardown restored, no AttributeError + + +def test_root_research_symbol_can_be_patched_to_none() -> None: + """``None`` is an intentional override, not the missing-value sentinel.""" + from unittest.mock import patch + + import mostlyright + + original = mostlyright.research + with patch("mostlyright.research", None): + assert mostlyright.research is None + assert mostlyright.research is original From 90f64132d40dc77a8c272de9f963e8d6ca9cc64a Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:16:40 +0200 Subject: [PATCH 07/13] fix(align): allow numeric categorical as-of keys --- packages/core/src/mostlyright/align.py | 4 ++++ packages/core/tests/test_align.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index d058bffe..d93d5e74 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -519,6 +519,10 @@ def _gate_asof_knowledge_dtype( ddt = spine[_DECISION_COL].dtype orderable = ( pd.api.types.is_numeric_dtype(kdt) + or ( + isinstance(kdt, pd.CategoricalDtype) + and pd.api.types.is_numeric_dtype(kdt.categories.dtype) + ) or pd.api.types.is_datetime64_any_dtype(kdt) or pd.api.types.is_timedelta64_dtype(kdt) ) diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index 4a8687d0..da07d6c2 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -290,6 +290,24 @@ def test_asof_numeric_keys_still_join_without_gate() -> None: assert out.loc[0, "met_temp_f"] == 1.0 +def test_asof_numeric_categorical_keys_still_join_without_gate() -> None: + """Identical numeric categoricals are orderable in pandas 2.x.""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + dtype = pd.CategoricalDtype(categories=[1, 5], ordered=True) + frame = pd.DataFrame({"station": ["A"], "seq": pd.Series([1], dtype=dtype), "temp_f": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": pd.Series([5], dtype=dtype)}) + + out = align(spine, (frame, spec)) + + assert out.loc[0, "met_temp_f"] == 1.0 + + def test_asof_equal_object_dtypes_raise_contract_error() -> None: """Equal dtypes are not enough — equal ``object`` keys also raise in pandas, so the gate must catch them with error copy (not a raw From 7158a2f566efe57b3aecc1f0f3bfb45ac641a234 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:00:48 +0200 Subject: [PATCH 08/13] fix(align): support orderable extension dtypes --- packages/core/src/mostlyright/align.py | 10 +++++++++ packages/core/tests/test_align.py | 29 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index d93d5e74..cbbc46b4 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -517,6 +517,7 @@ def _gate_asof_knowledge_dtype( return kdt = prefixed[kt_col].dtype ddt = spine[_DECISION_COL].dtype + numpy_dtype = getattr(kdt, "numpy_dtype", None) orderable = ( pd.api.types.is_numeric_dtype(kdt) or ( @@ -525,6 +526,15 @@ def _gate_asof_knowledge_dtype( ) or pd.api.types.is_datetime64_any_dtype(kdt) or pd.api.types.is_timedelta64_dtype(kdt) + or isinstance(kdt, pd.PeriodDtype) + or ( + numpy_dtype is not None + and ( + pd.api.types.is_numeric_dtype(numpy_dtype) + or pd.api.types.is_datetime64_any_dtype(numpy_dtype) + or pd.api.types.is_timedelta64_dtype(numpy_dtype) + ) + ) ) if kdt == ddt and orderable: return diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index da07d6c2..6c5e3360 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -308,6 +308,35 @@ def test_asof_numeric_categorical_keys_still_join_without_gate() -> None: assert out.loc[0, "met_temp_f"] == 1.0 +@pytest.mark.parametrize( + "dtype", + [pd.PeriodDtype(freq="D"), pd.ArrowDtype(__import__("pyarrow").duration("ns"))], +) +def test_asof_supported_extension_keys_still_join_without_gate(dtype) -> None: + """Period and Arrow duration keys are orderable in pandas 2.x.""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + if isinstance(dtype, pd.PeriodDtype): + left_value, right_value = pd.Period("2025-01-05"), pd.Period("2025-01-01") + else: + left_value, right_value = pd.Timedelta(days=5), pd.Timedelta(days=1) + frame = pd.DataFrame( + {"station": ["A"], "seq": pd.Series([right_value], dtype=dtype), "temp_f": [1.0]} + ) + spine = pd.DataFrame( + {"station": ["A"], "decision_time": pd.Series([left_value], dtype=dtype)} + ) + + out = align(spine, (frame, spec)) + + assert out.loc[0, "met_temp_f"] == 1.0 + + def test_asof_equal_object_dtypes_raise_contract_error() -> None: """Equal dtypes are not enough — equal ``object`` keys also raise in pandas, so the gate must catch them with error copy (not a raw From 55dff65faf656be31785f1e12c93c1beead57070 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:11:48 +0200 Subject: [PATCH 09/13] fix(align): defer as-of orderability to pandas --- packages/core/src/mostlyright/align.py | 82 ++++++++++++-------------- packages/core/tests/test_align.py | 31 ++++++++++ 2 files changed, 69 insertions(+), 44 deletions(-) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index cbbc46b4..7e01af1c 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -163,15 +163,39 @@ def _asof_join( by = list(spec.entity_key) left = spine.sort_values(_DECISION_COL, kind="mergesort").reset_index(drop=True) right = collapsed.sort_values(kt, kind="mergesort").reset_index(drop=True) - return pd.merge_asof( - left, - right, - left_on=_DECISION_COL, - right_on=kt, - by=by, - direction="backward", - allow_exact_matches=True, - ) + try: + return pd.merge_asof( + left, + right, + left_on=_DECISION_COL, + right_on=kt, + by=by, + direction="backward", + allow_exact_matches=True, + ) + except (pd.errors.MergeError, TypeError, ValueError) as exc: + from mostlyright.core.exceptions import ContractError + + if spec.event_time_col and spec.event_time_col not in spine.columns: + fix = ( + f"add the {spec.event_time_col!r} equality-join column to the spine, " + "or convert both as-of keys to a pandas-supported identical dtype" + ) + else: + fix = "convert both as-of keys to a pandas-supported identical ordered dtype" + raise ContractError( + contract_message( + source_id=spec.id, + field=spec.knowledge_time_col, + problem=( + f"pandas could not as-of-compare {spec.knowledge_time_col!r} " + f"to {_DECISION_COL!r}: {exc}" + ), + fix=fix, + ), + field=spec.knowledge_time_col, + source=spec.id, + ) from exc def _audit_source_leakage( @@ -507,36 +531,16 @@ def _gate_asof_knowledge_dtype( probed on pandas 2.x, pinned by tests): the two key dtypes must be IDENTICAL (including datetime resolution and timezone — ``int64/float64``, ``ns/us``, ``UTC/US-Eastern`` and naive/aware pairs all raise in pandas) - and belong to an as-of-orderable family (numeric, datetime, or timedelta — - equal ``object`` dtypes also raise in pandas). The gate therefore only - ever raises where ``merge_asof`` would raise anyway — a previously-working - input (e.g. a BYO spine as-of-joining on int64 keys) never changes - behavior. + The gate checks exact dtype equality. For identical dtypes, pandas itself + remains the authority on orderability; ``_asof_join`` translates its + incompatibility error to ``ContractError``. This avoids a narrower SDK + whitelist rejecting extension dtypes pandas supports. """ if not kt_col or kt_col not in prefixed.columns or _DECISION_COL not in spine.columns: return kdt = prefixed[kt_col].dtype ddt = spine[_DECISION_COL].dtype - numpy_dtype = getattr(kdt, "numpy_dtype", None) - orderable = ( - pd.api.types.is_numeric_dtype(kdt) - or ( - isinstance(kdt, pd.CategoricalDtype) - and pd.api.types.is_numeric_dtype(kdt.categories.dtype) - ) - or pd.api.types.is_datetime64_any_dtype(kdt) - or pd.api.types.is_timedelta64_dtype(kdt) - or isinstance(kdt, pd.PeriodDtype) - or ( - numpy_dtype is not None - and ( - pd.api.types.is_numeric_dtype(numpy_dtype) - or pd.api.types.is_datetime64_any_dtype(numpy_dtype) - or pd.api.types.is_timedelta64_dtype(numpy_dtype) - ) - ) - ) - if kdt == ddt and orderable: + if kdt == ddt: return from mostlyright.core.exceptions import ContractError @@ -553,16 +557,6 @@ def _gate_asof_knowledge_dtype( f"spine — weather.days() and the label factories carry it, and mr.spine() " f"can map it from your own frame" ) - elif not orderable: - problem = ( - f"knowledge_time_col {spec.knowledge_time_col!r} has dtype {kdt}, which is " - f"not an as-of-orderable key (numeric, datetime, or timedelta) and cannot " - f"be compared to the spine {_DECISION_COL!r} (dtype {ddt})" - ) - fix = ( - f"convert the source's {spec.knowledge_time_col!r} column to a " - f"timezone-aware datetime (e.g. pd.to_datetime(..., utc=True))" - ) else: problem = ( f"knowledge_time_col {spec.knowledge_time_col!r} (dtype {kdt}) and the " diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index 6c5e3360..1cdff7dd 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -337,6 +337,37 @@ def test_asof_supported_extension_keys_still_join_without_gate(dtype) -> None: assert out.loc[0, "met_temp_f"] == 1.0 +def test_asof_numeric_arrow_dictionary_keys_defer_to_pandas() -> None: + """The SDK must not reject extension keys that the active pandas accepts.""" + import pyarrow as pa + + dtype = pd.ArrowDtype(pa.dictionary(pa.int8(), pa.int64())) + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + frame = pd.DataFrame({"station": ["A"], "seq": pd.Series([1], dtype=dtype), "temp_f": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": pd.Series([5], dtype=dtype)}) + + try: + expected = pd.merge_asof( + spine.sort_values("decision_time"), + frame.sort_values("seq"), + left_on="decision_time", + right_on="seq", + by="station", + ) + except (pd.errors.MergeError, pa.ArrowNotImplementedError, TypeError, ValueError): + pytest.skip("active pandas does not support numeric Arrow dictionary as-of keys") + + out = align(spine, (frame, spec)) + + assert out.loc[0, "met_temp_f"] == expected.loc[0, "temp_f"] + + def test_asof_equal_object_dtypes_raise_contract_error() -> None: """Equal dtypes are not enough — equal ``object`` keys also raise in pandas, so the gate must catch them with error copy (not a raw From e9a4c1aa26e958a1b0b58d36dd5fd8e5243cafaa Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:15:41 +0200 Subject: [PATCH 10/13] fix(align): translate as-of sort failures --- packages/core/src/mostlyright/align.py | 4 ++-- packages/core/tests/test_align.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index 7e01af1c..4eeb2aa9 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -161,9 +161,9 @@ def _asof_join( """ kt = kt_col or spec.knowledge_time_col by = list(spec.entity_key) - left = spine.sort_values(_DECISION_COL, kind="mergesort").reset_index(drop=True) - right = collapsed.sort_values(kt, kind="mergesort").reset_index(drop=True) try: + left = spine.sort_values(_DECISION_COL, kind="mergesort").reset_index(drop=True) + right = collapsed.sort_values(kt, kind="mergesort").reset_index(drop=True) return pd.merge_asof( left, right, diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index 1cdff7dd..d4be99da 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -390,6 +390,28 @@ def test_asof_equal_object_dtypes_raise_contract_error() -> None: assert "met" in str(exc_info.value) +def test_asof_mixed_object_sort_error_is_translated() -> None: + """Sorting an unsupported mixed object key must not leak raw TypeError.""" + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + {"station": ["A", "A"], "seq": pd.Series([1, "2"], dtype=object), "temp_f": [1.0, 2.0]} + ) + spine = pd.DataFrame( + {"station": ["A", "A"], "decision_time": pd.Series([3, "4"], dtype=object)} + ) + + with pytest.raises(ContractError, match="pandas could not as-of-compare"): + align(spine, (frame, spec)) + + def test_asof_datetime_resolution_mismatch_raises_contract_error() -> None: """Codex review-iter-1 HIGH guard: both-aware but MIXED-RESOLUTION datetime keys (``us`` vs ``ns``) raise in pandas — the gate must catch the pair with From d3474101933c18f79f57891209b3d7dd7fdbe7ed Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:23:08 +0200 Subject: [PATCH 11/13] fix(align): report the actual invalid merge field --- packages/core/src/mostlyright/align.py | 41 +++++++++++++++++++-- packages/core/tests/test_align.py | 49 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index 4eeb2aa9..a060fbd4 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -161,6 +161,45 @@ def _asof_join( """ kt = kt_col or spec.knowledge_time_col by = list(spec.entity_key) + from mostlyright.core.exceptions import ContractError + + for key in by: + if spine[key].dtype != collapsed[key].dtype: + raise ContractError( + contract_message( + source_id=spec.id, + field=key, + problem=( + f"entity key {key!r} has dtype {collapsed[key].dtype} in the source " + f"but dtype {spine[key].dtype} in the spine" + ), + fix=f"convert both {key!r} columns to the same dtype before align()", + ), + field=key, + source=spec.id, + ) + if spine[_DECISION_COL].isna().any(): + raise ContractError( + contract_message( + source_id=spec.id, + field=_DECISION_COL, + problem=f"spine {_DECISION_COL!r} contains null merge keys", + fix=f"drop or fill null {_DECISION_COL!r} values before align()", + ), + field=_DECISION_COL, + source=spec.id, + ) + if collapsed[kt].isna().any(): + raise ContractError( + contract_message( + source_id=spec.id, + field=spec.knowledge_time_col, + problem=f"source knowledge key {spec.knowledge_time_col!r} contains null values", + fix=f"drop or fill null {spec.knowledge_time_col!r} values before align()", + ), + field=spec.knowledge_time_col, + source=spec.id, + ) try: left = spine.sort_values(_DECISION_COL, kind="mergesort").reset_index(drop=True) right = collapsed.sort_values(kt, kind="mergesort").reset_index(drop=True) @@ -174,8 +213,6 @@ def _asof_join( allow_exact_matches=True, ) except (pd.errors.MergeError, TypeError, ValueError) as exc: - from mostlyright.core.exceptions import ContractError - if spec.event_time_col and spec.event_time_col not in spine.columns: fix = ( f"add the {spec.event_time_col!r} equality-join column to the spine, " diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index d4be99da..889428f2 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -412,6 +412,55 @@ def test_asof_mixed_object_sort_error_is_translated() -> None: align(spine, (frame, spec)) +def test_asof_entity_key_dtype_mismatch_names_entity_field() -> None: + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + frame = pd.DataFrame({"station": pd.Series([1], dtype="int64"), "seq": [1], "temp_f": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": [5]}) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + assert exc_info.value.field == "station" + + +@pytest.mark.parametrize( + ("frame_seq", "spine_decision", "field"), + [ + (["2025-01-01T00:00:00Z"], [None], "decision_time"), + ([None], ["2025-01-05T00:00:00Z"], "seq"), + ], +) +def test_asof_null_merge_key_names_actual_field(frame_seq, spine_decision, field) -> None: + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="seq", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + {"station": ["A"], "seq": pd.to_datetime(frame_seq, utc=True), "temp_f": [1.0]} + ) + spine = pd.DataFrame( + {"station": ["A"], "decision_time": pd.to_datetime(spine_decision, utc=True)} + ) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + assert exc_info.value.field == field + + def test_asof_datetime_resolution_mismatch_raises_contract_error() -> None: """Codex review-iter-1 HIGH guard: both-aware but MIXED-RESOLUTION datetime keys (``us`` vs ``ns``) raise in pandas — the gate must catch the pair with From 6401317c052bf922821876244b6c03df69a3d592 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:31:49 +0200 Subject: [PATCH 12/13] fix(align): translate backend extension failures --- packages/core/src/mostlyright/align.py | 2 +- packages/core/tests/test_align.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index a060fbd4..23b95a9b 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -212,7 +212,7 @@ def _asof_join( direction="backward", allow_exact_matches=True, ) - except (pd.errors.MergeError, TypeError, ValueError) as exc: + except Exception as exc: if spec.event_time_col and spec.event_time_col not in spine.columns: fix = ( f"add the {spec.event_time_col!r} equality-join column to the spine, " diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index 889428f2..cd62920e 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -360,8 +360,12 @@ def test_asof_numeric_arrow_dictionary_keys_defer_to_pandas() -> None: right_on="seq", by="station", ) - except (pd.errors.MergeError, pa.ArrowNotImplementedError, TypeError, ValueError): - pytest.skip("active pandas does not support numeric Arrow dictionary as-of keys") + except Exception: + from mostlyright.core.exceptions import ContractError + + with pytest.raises(ContractError, match="pandas could not as-of-compare"): + align(spine, (frame, spec)) + return out = align(spine, (frame, spec)) From c00cf127bd2ee28b4deb806c4553e3badf7fd845 Mon Sep 17 00:00:00 2001 From: minereda <84080887+minereda@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:38:29 +0200 Subject: [PATCH 13/13] fix(align): validate null keys before dtype equality --- packages/core/src/mostlyright/align.py | 26 ++++++++++++++++++++++++-- packages/core/tests/test_align.py | 4 +--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py index 23b95a9b..1c4230f4 100644 --- a/packages/core/src/mostlyright/align.py +++ b/packages/core/src/mostlyright/align.py @@ -575,13 +575,35 @@ def _gate_asof_knowledge_dtype( """ if not kt_col or kt_col not in prefixed.columns or _DECISION_COL not in spine.columns: return + from mostlyright.core.exceptions import ContractError + + if spine[_DECISION_COL].isna().any(): + raise ContractError( + contract_message( + source_id=spec.id, + field=_DECISION_COL, + problem=f"spine {_DECISION_COL!r} contains null merge keys", + fix=f"drop or fill null {_DECISION_COL!r} values before align()", + ), + field=_DECISION_COL, + source=spec.id, + ) + if prefixed[kt_col].isna().any(): + raise ContractError( + contract_message( + source_id=spec.id, + field=spec.knowledge_time_col, + problem=f"source knowledge key {spec.knowledge_time_col!r} contains null values", + fix=f"drop or fill null {spec.knowledge_time_col!r} values before align()", + ), + field=spec.knowledge_time_col, + source=spec.id, + ) kdt = prefixed[kt_col].dtype ddt = spine[_DECISION_COL].dtype if kdt == ddt: return - from mostlyright.core.exceptions import ContractError - if spec.event_time_col and spec.event_time_col not in spine.columns: problem = ( f"the spine is missing this source's equality-join key " diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py index cd62920e..7ca59964 100644 --- a/packages/core/tests/test_align.py +++ b/packages/core/tests/test_align.py @@ -328,9 +328,7 @@ def test_asof_supported_extension_keys_still_join_without_gate(dtype) -> None: frame = pd.DataFrame( {"station": ["A"], "seq": pd.Series([right_value], dtype=dtype), "temp_f": [1.0]} ) - spine = pd.DataFrame( - {"station": ["A"], "decision_time": pd.Series([left_value], dtype=dtype)} - ) + spine = pd.DataFrame({"station": ["A"], "decision_time": pd.Series([left_value], dtype=dtype)}) out = align(spine, (frame, spec))