From 4c1b9da1902ce7e4dad49f7b017caeede3abaa40 Mon Sep 17 00:00:00 2001 From: Mathews-Tom Date: Fri, 17 Apr 2026 17:04:22 +0530 Subject: [PATCH] feat: cross-package compatibility pins and runtime gates Tighten augur-signals dependency specifier in both dependent packages to ~= 0.1.0 so a transitive resolver cannot drag augur-signals past the 0.1.x range that the installed augur-labels / augur-format was compiled against. Add a two-part runtime gate in augur_labels._compat and augur_format._compat, invoked from each package __init__: 1. Reads importlib.metadata.version('augur-signals') and rejects anything outside [_SIGNALS_MIN, _SIGNALS_LT). 2. Asserts augur_signals.models.MODELS_SCHEMA_VERSION major digit matches the downstream-expected major, catching cases where a version-pin loophole somehow delivered a mismatched schema. Expose MODELS_SCHEMA_VERSION as a module-level constant in augur_signals.models so the contract is a single source of truth instead of scattered Literal annotations across every Pydantic model. Bump augur-labels and augur-format __version__ strings from 0.0.0 to 0.1.0 to align with the v0.1.0 workspace release. Document the policy in docs/contracts/cross-package-compatibility.md: compatible-release specifier, lock-step release rule, major/minor/ patch bump semantics, runtime gate behaviour, release procedure, and the rationale against collapsing to a single package. 14 new unit tests exercise the gates against manufactured version strings without requiring a mismatched install. --- docs/README.md | 19 ++-- docs/contracts/cross-package-compatibility.md | 98 +++++++++++++++++++ src/augur_format/augur_format/__init__.py | 6 +- src/augur_format/augur_format/_compat.py | 82 ++++++++++++++++ src/augur_format/pyproject.toml | 2 +- src/augur_labels/augur_labels/__init__.py | 6 +- src/augur_labels/augur_labels/_compat.py | 86 ++++++++++++++++ src/augur_labels/pyproject.toml | 2 +- .../augur_signals/models/__init__.py | 10 ++ tests/test_cross_package_compat.py | 78 +++++++++++++++ 10 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 docs/contracts/cross-package-compatibility.md create mode 100644 src/augur_format/augur_format/_compat.py create mode 100644 src/augur_labels/augur_labels/_compat.py create mode 100644 tests/test_cross_package_compat.py diff --git a/docs/README.md b/docs/README.md index 7f2942a..eb3dc81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,15 +29,16 @@ Read these before writing or modifying any code: 1. `contracts/schema-and-versioning.md` — every data contract 2. `contracts/consumer-registry.md` — closed `ConsumerType` enum -3. `methodology/calibration-methodology.md` — confidence pipeline -4. `methodology/labeling-protocol.md` — ground-truth definition -5. `methodology/manipulation-taxonomy.md` — manipulation signatures -6. `architecture/system-design.md` — layer-by-layer architecture (includes Deployment Modes) -7. `architecture/adaptive-polling-spec.md` — polling state machine -8. `architecture/deduplication-and-storms.md` — signal merge algorithm -9. `architecture/storage-and-scaling.md` — storage architecture and migration triggers -10. `operations/distributed-runbook.md` — cutover, rollback, failover procedures -11. `operations/manual-testing.md` — runnable surfaces and local smoke stack +3. `contracts/cross-package-compatibility.md` — cross-package version and schema gates +4. `methodology/calibration-methodology.md` — confidence pipeline +5. `methodology/labeling-protocol.md` — ground-truth definition +6. `methodology/manipulation-taxonomy.md` — manipulation signatures +7. `architecture/system-design.md` — layer-by-layer architecture (includes Deployment Modes) +8. `architecture/adaptive-polling-spec.md` — polling state machine +9. `architecture/deduplication-and-storms.md` — signal merge algorithm +10. `architecture/storage-and-scaling.md` — storage architecture and migration triggers +11. `operations/distributed-runbook.md` — cutover, rollback, failover procedures +12. `operations/manual-testing.md` — runnable surfaces and local smoke stack ## Group Index diff --git a/docs/contracts/cross-package-compatibility.md b/docs/contracts/cross-package-compatibility.md new file mode 100644 index 0000000..86286f3 --- /dev/null +++ b/docs/contracts/cross-package-compatibility.md @@ -0,0 +1,98 @@ +# Cross-Package Compatibility Policy + +Augur ships as three independent PyPI packages (`augur-signals`, `augur-labels`, `augur-format`). The packages share Pydantic contracts in `augur_signals.models` and must agree on the schema at install time. This document pins the versioning rules and the runtime gates that enforce them. + +## Version alignment rule + +All three workspace packages are released **in lock step**. A release cuts `vX.Y.Z` for all three simultaneously; there is no release where `augur-labels` and `augur-format` point at different `augur-signals` versions in their pinned range. + +### Dependency specifier + +Each dependent package pins `augur-signals` with a compatible-release specifier on the minor version: + +```toml +# augur-labels/pyproject.toml, augur-format/pyproject.toml +"augur-signals ~= 0.1.0" # >=0.1.0, <0.2.0 +``` + +Patch upgrades (`0.1.0 → 0.1.1`) are permitted transparently. A minor bump (`0.1.x → 0.2.0`) **must** re-cut `augur-labels` and `augur-format` with an updated specifier before publishing. + +### Major vs minor vs patch + +Pre-1.0 semantics (0.x phase): + +| Bump | When | Downstream action | +| --- | --- | --- | +| `0.1.Z → 0.1.Z+1` | Internal refactor, additive API, bug fix that preserves schema | None. All three packages co-publish a patch. | +| `0.1.Z → 0.2.0` | Breaking change to any exported contract (`MarketSignal`, `SignalContext`, `FeatureVector`, `EventBus`, etc.), even if field-additive | Downstream pin tightens to `~= 0.2.0`; MODELS_SCHEMA_VERSION major may bump. | +| `0.Y.Z → 1.0.0` | API stability commitment | Full semver applies from here on. | + +After 1.0, a major bump in `augur-signals` forces a major bump in both dependents (their import-time gates refuse the major-mismatch). + +## Runtime compatibility gate + +Both `augur-labels` and `augur-format` run a two-part check at package import time. The gate is in each package's `_compat.py` module and is invoked from `__init__.py` *before any public symbol is re-exported*. + +### Gate 1 — installed version range + +```python +# Parses importlib.metadata.version("augur-signals") and rejects anything +# outside [_SIGNALS_MIN, _SIGNALS_LT). +_SIGNALS_MIN: Final[str] = "0.1.0" +_SIGNALS_LT: Final[str] = "0.2.0" +``` + +Catches two failure modes: + +1. A user ran `pip install --no-deps` or a private index served a broken set. +2. A transitive dependency upgraded `augur-signals` past the compatible range without bumping this package. + +### Gate 2 — schema-contract major version + +```python +from augur_signals.models import MODELS_SCHEMA_VERSION +# Rejects when the major digit does not match _EXPECTED_MODELS_SCHEMA_MAJOR. +``` + +`MODELS_SCHEMA_VERSION` is the canonical constant in `augur_signals.models`. Every Pydantic model in that package pins `schema_version: Literal["X.Y.Z"]` to the same value. A major bump in the constant signals a breaking schema change that requires downstream rebuild; the gate refuses to proceed with a mismatched dependent build. + +### Failure mode + +Both gates raise `IncompatibleAugurSignals`, a subclass of `ImportError`. The error message names the observed version, the expected range, and points at this document. The package fails to import — no partial import, no soft warning, no graceful degradation. + +``` +augur-labels requires augur-signals >=0.1.0,<0.2.0; found 0.2.3. +Align the package versions (same minor) before running. +See docs/contracts/cross-package-compatibility.md. +``` + +## Release procedure + +1. Change any exported contract in `augur-signals`. +2. If the change is breaking, bump `MODELS_SCHEMA_VERSION` major. +3. Bump all three package versions in lock step: + - `pyproject.toml` (root) + - `src/augur_signals/pyproject.toml` + - `src/augur_labels/pyproject.toml` + - `src/augur_format/pyproject.toml` +4. Update the dependent pins: + - `augur-labels` and `augur-format` change `"augur-signals ~= X.Y.0"`. + - `augur_labels/_compat.py` and `augur_format/_compat.py` update `_SIGNALS_MIN`, `_SIGNALS_LT`, and `_EXPECTED_MODELS_SCHEMA_MAJOR` in the same commit. +5. Update `CHANGELOG.md` under `## [Unreleased]`. +6. Run gates, tag `vX.Y.Z`, build and publish all three wheels in one `uv publish` invocation. + +Release automation enforcing the lock-step bump is a follow-up (tracked against v0.2.0). + +## Why not collapse into one package + +A single `augur` package with optional-dependency subsets would eliminate cross-package version drift at the cost of: + +- Losing the physical LLM-isolation boundary (no import of `anthropic` / `ollama` possible anywhere in `augur_signals`). The CI grep guard would become the only defence. +- A larger install closure for deployments that need only one slice (a signal-extraction worker would still resolve the labeling and formatting submodules' metadata). +- Releases coupling bug fixes across domains (a labeling-only patch forces a re-release of the formatter). + +The three-package split accepts cross-version drift risk in exchange for enforceable isolation and independently-installable subsets. The gates in this document bound the risk to same-minor drift only. + +## Scope + +This policy applies only to the three `augur-*` workspace packages. Optional-dependency extras on each package (`llm-local`, `llm-cloud`, `bus-nats`, etc.) follow each wrapped SDK's own versioning conventions and are not gated here. diff --git a/src/augur_format/augur_format/__init__.py b/src/augur_format/augur_format/__init__.py index c89d268..23ef0c2 100644 --- a/src/augur_format/augur_format/__init__.py +++ b/src/augur_format/augur_format/__init__.py @@ -2,4 +2,8 @@ from __future__ import annotations -__version__ = "0.0.0" +from augur_format._compat import check_compatibility + +__version__ = "0.1.0" + +check_compatibility() diff --git a/src/augur_format/augur_format/_compat.py b/src/augur_format/augur_format/_compat.py new file mode 100644 index 0000000..95e5381 --- /dev/null +++ b/src/augur_format/augur_format/_compat.py @@ -0,0 +1,82 @@ +"""Cross-package compatibility gate for `augur-format`. + +Runs at package import time and fails loud when the resolved +`augur-signals` version or its schema contract sits outside the +range this `augur-format` build was compiled against. + +See `docs/contracts/cross-package-compatibility.md` for the policy. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version +from typing import Final + +# Compatibility window on augur-signals. Change these in lock step +# with the `"augur-signals ~=X.Y.0"` specifier in pyproject.toml and +# with the MODELS_SCHEMA_VERSION expected below. +_SIGNALS_MIN: Final[str] = "0.1.0" +_SIGNALS_LT: Final[str] = "0.2.0" + +_EXPECTED_MODELS_SCHEMA_MAJOR: Final[str] = "1" + + +class IncompatibleAugurSignalsError(ImportError): + """Raised when the resolved `augur-signals` violates the compat range.""" + + +def _parse_version(v: str) -> tuple[int, int, int]: + core = v.split("+", maxsplit=1)[0].split("-", maxsplit=1)[0] + parts = core.split(".") + if len(parts) < 3: + parts = [*parts, *(["0"] * (3 - len(parts)))] + try: + return ( + int(parts[0]), + int(parts[1]), + int( + parts[2].split("rc")[0].split("a")[0].split("b")[0].split("dev")[0].split("post")[0] + or "0" + ), + ) + except ValueError as exc: + raise IncompatibleAugurSignalsError(f"Unparseable augur-signals version: {v!r}") from exc + + +def _require_range(installed: str) -> None: + got = _parse_version(installed) + lo = _parse_version(_SIGNALS_MIN) + hi = _parse_version(_SIGNALS_LT) + if not (lo <= got < hi): + raise IncompatibleAugurSignalsError( + f"augur-format requires augur-signals >={_SIGNALS_MIN},<{_SIGNALS_LT}; " + f"found {installed}. Align the package versions (same minor) before " + "running. See docs/contracts/cross-package-compatibility.md." + ) + + +def _require_schema_major() -> None: + from augur_signals.models import MODELS_SCHEMA_VERSION + + observed_major = MODELS_SCHEMA_VERSION.split(".", maxsplit=1)[0] + if observed_major != _EXPECTED_MODELS_SCHEMA_MAJOR: + raise IncompatibleAugurSignalsError( + f"augur-format expects augur-signals MODELS_SCHEMA_VERSION major " + f"{_EXPECTED_MODELS_SCHEMA_MAJOR}, got " + f"{MODELS_SCHEMA_VERSION}. This is a cross-package schema " + "mismatch — upgrade augur-format or downgrade augur-signals." + ) + + +def check_compatibility() -> None: + """Run both gates; raises `IncompatibleAugurSignalsError` on mismatch.""" + try: + installed = version("augur-signals") + except PackageNotFoundError as exc: + raise IncompatibleAugurSignalsError( + "augur-signals is not installed but is a required dependency " + "of augur-format. Install it via `uv sync` or " + "`pip install augur-signals~=0.1.0`." + ) from exc + _require_range(installed) + _require_schema_major() diff --git a/src/augur_format/pyproject.toml b/src/augur_format/pyproject.toml index 9ac6ba7..8a5bfda 100644 --- a/src/augur_format/pyproject.toml +++ b/src/augur_format/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "jinja2>=3.1", "httpx>=0.27", "websockets>=13.0", - "augur-signals", + "augur-signals ~= 0.1.0", ] [project.optional-dependencies] diff --git a/src/augur_labels/augur_labels/__init__.py b/src/augur_labels/augur_labels/__init__.py index a8949ed..d7696f1 100644 --- a/src/augur_labels/augur_labels/__init__.py +++ b/src/augur_labels/augur_labels/__init__.py @@ -2,4 +2,8 @@ from __future__ import annotations -__version__ = "0.0.0" +from augur_labels._compat import check_compatibility + +__version__ = "0.1.0" + +check_compatibility() diff --git a/src/augur_labels/augur_labels/_compat.py b/src/augur_labels/augur_labels/_compat.py new file mode 100644 index 0000000..3c51921 --- /dev/null +++ b/src/augur_labels/augur_labels/_compat.py @@ -0,0 +1,86 @@ +"""Cross-package compatibility gate for `augur-labels`. + +Runs at package import time and fails loud when the resolved +`augur-signals` version or its schema contract sits outside the +range this `augur-labels` build was compiled against. + +See `docs/contracts/cross-package-compatibility.md` for the policy. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version +from typing import Final + +# Compatibility window on augur-signals. Change these in lock step +# with the `"augur-signals ~=X.Y.0"` specifier in pyproject.toml and +# with the MODELS_SCHEMA_VERSION expected below. +_SIGNALS_MIN: Final[str] = "0.1.0" +_SIGNALS_LT: Final[str] = "0.2.0" + +# Schema-contract major version. Bumping the augur-signals models' major +# schema_version bumps this; augur-labels must be re-released with the +# matching expected value. +_EXPECTED_MODELS_SCHEMA_MAJOR: Final[str] = "1" + + +class IncompatibleAugurSignalsError(ImportError): + """Raised when the resolved `augur-signals` violates the compat range.""" + + +def _parse_version(v: str) -> tuple[int, int, int]: + # Strip PEP 440 suffixes (post/rc/dev); accept "X.Y.Z" prefix. + core = v.split("+", maxsplit=1)[0].split("-", maxsplit=1)[0] + parts = core.split(".") + if len(parts) < 3: + parts = [*parts, *(["0"] * (3 - len(parts)))] + try: + return ( + int(parts[0]), + int(parts[1]), + int( + parts[2].split("rc")[0].split("a")[0].split("b")[0].split("dev")[0].split("post")[0] + or "0" + ), + ) + except ValueError as exc: + raise IncompatibleAugurSignalsError(f"Unparseable augur-signals version: {v!r}") from exc + + +def _require_range(installed: str) -> None: + got = _parse_version(installed) + lo = _parse_version(_SIGNALS_MIN) + hi = _parse_version(_SIGNALS_LT) + if not (lo <= got < hi): + raise IncompatibleAugurSignalsError( + f"augur-labels requires augur-signals >={_SIGNALS_MIN},<{_SIGNALS_LT}; " + f"found {installed}. Align the package versions (same minor) before " + "running. See docs/contracts/cross-package-compatibility.md." + ) + + +def _require_schema_major() -> None: + from augur_signals.models import MODELS_SCHEMA_VERSION + + observed_major = MODELS_SCHEMA_VERSION.split(".", maxsplit=1)[0] + if observed_major != _EXPECTED_MODELS_SCHEMA_MAJOR: + raise IncompatibleAugurSignalsError( + f"augur-labels expects augur-signals MODELS_SCHEMA_VERSION major " + f"{_EXPECTED_MODELS_SCHEMA_MAJOR}, got " + f"{MODELS_SCHEMA_VERSION}. This is a cross-package schema " + "mismatch — upgrade augur-labels or downgrade augur-signals." + ) + + +def check_compatibility() -> None: + """Run both gates; raises `IncompatibleAugurSignalsError` on mismatch.""" + try: + installed = version("augur-signals") + except PackageNotFoundError as exc: + raise IncompatibleAugurSignalsError( + "augur-signals is not installed but is a required dependency " + "of augur-labels. Install it via `uv sync` or " + "`pip install augur-signals~=0.1.0`." + ) from exc + _require_range(installed) + _require_schema_major() diff --git a/src/augur_labels/pyproject.toml b/src/augur_labels/pyproject.toml index 5d57620..893d582 100644 --- a/src/augur_labels/pyproject.toml +++ b/src/augur_labels/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "httpx>=0.27", "click>=8.1", "filelock>=3.15", - "augur-signals", + "augur-signals ~= 0.1.0", ] [build-system] diff --git a/src/augur_signals/augur_signals/models/__init__.py b/src/augur_signals/augur_signals/models/__init__.py index b8ddd82..ea38de2 100644 --- a/src/augur_signals/augur_signals/models/__init__.py +++ b/src/augur_signals/augur_signals/models/__init__.py @@ -3,10 +3,17 @@ Schemas are authoritative in docs/contracts/schema-and-versioning.md. Every exported model sets schema_version to "1.0.0"; major-version bumps follow the versioning policy in that document. + +MODELS_SCHEMA_VERSION is the single source of truth for the major +schema contract. Dependent packages (augur-labels, augur-format) +assert compatibility against it at import time — see +docs/contracts/cross-package-compatibility.md. """ from __future__ import annotations +from typing import Final + from augur_signals.models._identifiers import new_signal_id from augur_signals.models.context import RelatedMarketState, SignalContext from augur_signals.models.enums import ( @@ -19,7 +26,10 @@ from augur_signals.models.signal import MarketSignal from augur_signals.models.snapshot import MarketSnapshot +MODELS_SCHEMA_VERSION: Final[str] = "1.0.0" + __all__ = [ + "MODELS_SCHEMA_VERSION", "ConsumerType", "FeatureVector", "InterpretationMode", diff --git a/tests/test_cross_package_compat.py b/tests/test_cross_package_compat.py new file mode 100644 index 0000000..bb86c91 --- /dev/null +++ b/tests/test_cross_package_compat.py @@ -0,0 +1,78 @@ +"""Cross-package compatibility gate tests. + +Both dependent packages (augur-labels, augur-format) run a +`check_compatibility()` at import time. These tests exercise the gate +directly against manufactured version strings so the installed +workspace is not required to simulate a mismatch. +""" + +from __future__ import annotations + +import pytest + +import augur_format._compat as fmt_compat +import augur_labels._compat as lbl_compat + + +@pytest.mark.unit +def test_parse_version_handles_prerelease_suffixes() -> None: + assert lbl_compat._parse_version("0.1.0") == (0, 1, 0) + assert lbl_compat._parse_version("0.1.0rc1") == (0, 1, 0) + assert lbl_compat._parse_version("0.1.0.post1") == (0, 1, 0) + assert lbl_compat._parse_version("0.1.0+local") == (0, 1, 0) + + +@pytest.mark.unit +def test_parse_version_rejects_garbage() -> None: + with pytest.raises(lbl_compat.IncompatibleAugurSignalsError): + lbl_compat._parse_version("not-a-version") + + +@pytest.mark.unit +@pytest.mark.parametrize("good", ["0.1.0", "0.1.3", "0.1.99", "0.1.0rc1"]) +def test_require_range_accepts_in_window(good: str) -> None: + lbl_compat._require_range(good) + fmt_compat._require_range(good) + + +@pytest.mark.unit +@pytest.mark.parametrize("bad", ["0.0.9", "0.2.0", "0.2.1", "1.0.0"]) +def test_require_range_rejects_out_of_window(bad: str) -> None: + with pytest.raises(lbl_compat.IncompatibleAugurSignalsError, match="augur-signals"): + lbl_compat._require_range(bad) + with pytest.raises(fmt_compat.IncompatibleAugurSignalsError, match="augur-signals"): + fmt_compat._require_range(bad) + + +@pytest.mark.unit +def test_require_schema_major_accepts_current() -> None: + lbl_compat._require_schema_major() + fmt_compat._require_schema_major() + + +@pytest.mark.unit +def test_require_schema_major_rejects_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: + import augur_signals.models as signals_models + + monkeypatch.setattr(signals_models, "MODELS_SCHEMA_VERSION", "2.0.0") + with pytest.raises(lbl_compat.IncompatibleAugurSignalsError, match="schema"): + lbl_compat._require_schema_major() + with pytest.raises(fmt_compat.IncompatibleAugurSignalsError, match="schema"): + fmt_compat._require_schema_major() + + +@pytest.mark.unit +def test_check_compatibility_succeeds_on_current_workspace() -> None: + # Workspace editable install means augur-signals resolves to 0.1.0 + # and MODELS_SCHEMA_VERSION is "1.0.0"; both gates pass. + lbl_compat.check_compatibility() + fmt_compat.check_compatibility() + + +@pytest.mark.unit +def test_exported_version_is_aligned() -> None: + import augur_format + import augur_labels + + assert augur_labels.__version__ == "0.1.0" + assert augur_format.__version__ == "0.1.0"