diff --git a/CHANGELOG.md b/CHANGELOG.md index ef921d8..95a65b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.11.1 - 2026-10-04 + +- Fixed the Hermes live-compression adapter's missing `_coerce_threshold_tokens_cap` + method and lazy context-window/threshold cache invalidation. +- Reused installed Hermes route metadata and per-model threshold handling; current + `select_context` budgets refresh the window without resetting retry/usage state. +- Preserved independent cloned-engine configuration, exact recovery, internal-call + isolation and the existing strict tokenizer acceptance gate. +- Added nine real-Hermes regression cases and corrected their Ruff formatting. +- Updated release/install references and corrected the README's stale context-engine + FAQ. Rust companion version aligned; its runtime behavior is unchanged. +- Unknown-model tokenizers and the host's plugin-discovery race remain separate + limitations; publication does not install or restart a running Hermes profile. + ## 0.11.0 - 2026-09-29 **Context, learning & observability.** Includes the unshipped 0.10.0 milestone; diff --git a/Cargo.toml b/Cargo.toml index ffb9760..8f98d97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "token-terminator" -version = "0.11.0" +version = "0.11.1" edition = "2021" rust-version = "1.78" description = "Rust interoperability helpers for Token Terminator artifact identities and reduction invariants" diff --git a/MIGRATION.md b/MIGRATION.md index 50a1f31..86e2a72 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,27 @@ +# Migration to Token Terminator 0.11.1 + +## 0.11.0 to 0.11.1 + +This is the PR #20 Hermes live-compression compatibility repair. No new key, +database migration, tokenizer override or engine-selection change is required. +Upgrade the environment actually used by the managed Hermes launcher, not an +unrelated Python environment or stale venv copy: + +```bash +python -m pip install --upgrade 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' +python -m rtk_hermes_plus.cli install-context-engine +``` + +Confirm the imported `rtk_hermes_plus` path in that environment. When the relevant +backend is idle, restart it once through its normal supervisor; do not start a +competing gateway or delete histories/vaults. Verify the loaded version and +current-model request metrics. A healthy restart or a lower UI percentage alone +does not prove reduction. Unknown target tokenizers still bypass the engine. +See [the compatibility guide](docs/LIVE_COMPRESSION_COMPAT.md). These instructions +do not imply publication performs a live installation or restart. + +--- + # Migration to Token Terminator 0.11.0 ## Optional learned policy diff --git a/README.md b/README.md index a6d1b9a..ac82ffa 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@
-
+
-
+
@@ -27,6 +27,19 @@ Token Terminator is an agent-runtime optimization layer. It removes token bloat
Documentation: see the [GitHub Wiki](https://github.com/AronAxe/Token-Terminator/wiki) for quick start, architecture, configuration, recovery, security, troubleshooting, migration, and release notes.
+## Patch v0.11.1: Hermes live-compression compatibility
+
+Repairs the missing `_coerce_threshold_tokens_cap` adapter method and live
+window/threshold invalidation. The selected TT engine now follows Hermes'
+current route budget and isolates cloned engine settings. No new settings,
+generative summarizer or relaxed tokenizer gate are introduced.
+
+For managed/multiplex installs, upgrade the environment that actually imports
+TT, then restart the appropriate idle backend through its normal supervisor.
+This patch does not add unknown-model tokenizer mappings or fix Hermes' separate
+concurrent plugin-discovery race. [Patch notes](docs/releases/v0.11.1.md) ·
+[Compatibility details](docs/LIVE_COMPRESSION_COMPAT.md).
+
## New in v0.11.0
**Context, learning & observability.** This release includes the unshipped 0.10.0
@@ -146,12 +159,12 @@ See [JEV roles and implemented scope](docs/JEV_ROLES.md) and
## Select Token Terminator as the Hermes ContextEngine
-Install **v0.11.0** in the Python environment that runs Hermes, then install its
+Install **v0.11.1** in the Python environment that runs Hermes, then install its
managed adapter. Replace `python` below with that environment's interpreter:
```bash
python -m pip install --upgrade \
- 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0'
+ 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1'
python -m rtk_hermes_plus.cli install-context-engine
```
@@ -309,9 +322,9 @@ The optional working-state block defaults to zero characters, even in `balanced`
## Install: Hermes Agent (turnkey)
-Token Terminator 0.11.0 builds on v0.9.0 and replaces the earlier `rtk-hermes-plus` distribution. `token-terminator` and `rtk-hermes-plus` must not coexist because both own the `rtk_hermes_plus` Python import package.
+Token Terminator 0.11.1 builds on v0.9.0 and replaces the earlier `rtk-hermes-plus` distribution. `token-terminator` and `rtk-hermes-plus` must not coexist because both own the `rtk_hermes_plus` Python import package.
-This is the supported zero-glue installation: the repository already contains the Hermes hooks, slash command, recovery tool, and lifecycle accounting. The commands below pin the `v0.11.0` release tag.
+This is the supported zero-glue installation: the repository already contains the Hermes hooks, slash command, recovery tool, and lifecycle accounting. The commands below pin the `v0.11.1` release tag.
### 1. Install RTK when using terminal rewriting
@@ -333,7 +346,7 @@ HERMES_PY="$HOME/.hermes/hermes-agent/venv/bin/python"
hermes plugins disable rtk-plus
"$HERMES_PY" -m pip uninstall -y rtk-hermes-plus token-terminator
"$HERMES_PY" -m pip install \
- 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0'
+ 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1'
```
Windows example:
@@ -342,7 +355,7 @@ Windows example:
$HermesPy = "$env:LOCALAPPDATA\hermes\hermes-agent\venv\Scripts\python.exe"
hermes plugins disable rtk-plus
& $HermesPy -m pip uninstall -y rtk-hermes-plus token-terminator
-& $HermesPy -m pip install "git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0"
+& $HermesPy -m pip install "git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1"
```
`tiktoken` now ships with Token Terminator and is used automatically for supported OpenAI-family models, including common provider-qualified model IDs. Hugging Face `tokenizers` remains optional when pointing Token Terminator at a local `tokenizer.json`:
@@ -374,7 +387,7 @@ Installation and enablement are separate operations. Disabling affects subsequen
Rust agent hosts can use the supported `token-terminator` companion crate for the stable cross-language pieces of the Token Terminator contract:
```bash
-cargo add token-terminator@0.11.0
+cargo add token-terminator@0.11.1
```
```rust
@@ -396,7 +409,7 @@ Install the same distribution in the environment that owns your agent loop:
```bash
python -m pip install \
- 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0'
+ 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1'
```
Then connect your runtime's tool-result and final-request hooks to `Runtime`. The adapter must map equivalent tools to Token Terminator's canonical names (`search_files`, `process`, and optionally `read_file`) and expose `Runtime.tool` to the model for exact recovery.
@@ -649,7 +662,7 @@ No. Provider-visible content may be compacted, and `artifact_peek` is deliberate
### Does it replace the host's memory or context engine?
-No. Token Terminator operates after or alongside normal context assembly. It does not own the transcript, alter persisted conversation history, or require a particular memory engine.
+It can replace the selected **Hermes context engine**, including LCM, when explicitly configured with `context.engine: token-terminator`. Middleware-only mode keeps the host engine upstream. Neither mode replaces the memory system or transcript store, or destructively rewrites persisted conversation history.
### Does enabling Jev turn off the normal Token Terminator pipeline?
diff --git a/docs/LIVE_COMPRESSION_COMPAT.md b/docs/LIVE_COMPRESSION_COMPAT.md
new file mode 100644
index 0000000..77c9a17
--- /dev/null
+++ b/docs/LIVE_COMPRESSION_COMPAT.md
@@ -0,0 +1,36 @@
+# Live-compression adapter repair
+
+The v0.11.0 adapter omitted the compatibility surface used by current Hermes
+`_apply_live_compression_config`. That host function updates the selected
+`context_compressor` even when it is a plugin ContextEngine, including calling
+`_coerce_threshold_tokens_cap` and clearing lazy window/threshold caches.
+
+This patch supplies positive-int/None cap normalization, lazy threshold and window
+invalidation, current-route metadata resolution when an explicit pin is removed,
+per-model threshold handling and isolated clone state. The current host request's
+`budget_tokens` refreshes the engine's window without resetting response counters.
+No hardcoded 272k or assumed replacement window is introduced. Host-side metadata
+is reused; the underlying request tokenizer and reduction acceptance gate are not
+relaxed. Internal service calls remain excluded. No generative compressor is built.
+
+Nine offline regression tests passed against the installed Hermes checkout
+`188a1a5efd098f067638032fea6e24c42d06178f` on Python 3.14. The tests call the real
+live-config function with mocked model metadata and disposable stores. They do not
+claim paid-provider validation or a reduction of an existing live session.
+
+Release scope: v0.11.1 publishes PR #20's adapter and tests. Earlier incident
+deployment notes are historical; a separately deployed adapter snapshot is not
+proof that this exact release has loaded in a running process. Installation,
+engine discovery and actual current-model request reduction remain separate
+checks. This release performs no live installation or backend restart.
+
+The observed host directory loader can expose a partially initialized cached module
+to simultaneous discovery. This adapter patch does not modify that upstream loader
+or claim to resolve its race. Deployment must use the host's managed plugin update
+path and a controlled backend restart, then check loaded engine identity and actual
+provider-request metrics. Do not kill unrelated relay/messaging/dashboard processes,
+delete conversation/vault data, or claim a reset UI percentage proves reduction.
+
+Unknown target tokenizers still cause ContextEngine/IR to pass through. This
+patch does not supply new tokenizer mappings, guess a replacement model window,
+or guarantee a reduction in a live session.
diff --git a/docs/releases/v0.11.1.md b/docs/releases/v0.11.1.md
new file mode 100644
index 0000000..4c1b939
--- /dev/null
+++ b/docs/releases/v0.11.1.md
@@ -0,0 +1,57 @@
+# Token Terminator v0.11.1 — Hermes live-compression compatibility
+
+A focused patch release of PR #20. The v0.11.0 ContextEngine, learned JEV policy,
+Context IR, exact-evidence vault and Observatory remain in place.
+
+## Fixed
+
+- Supply `_coerce_threshold_tokens_cap` for Hermes' live-compression configuration.
+- Honor invalidated context-window and threshold caches, explicit pin removal,
+ current-route metadata and per-model threshold handling.
+- Refresh the engine window from the host's current `select_context` budget
+ without resetting retry bindings or usage counters.
+- Keep cloned engines' settings independent. No generative compressor, destructive
+ history truncation, new key or weakened tokenizer gate is introduced.
+- Include nine real-Hermes regression cases and the formatting-only CI correction.
+
+## Upgrade
+
+Use the Python environment actually loaded by your managed Hermes launcher:
+
+```bash
+python -m pip install --upgrade 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1'
+python -m rtk_hermes_plus.cli install-context-engine
+```
+
+Keep your existing plugin/engine selection and JEV settings. Once idle, restart
+only the relevant backend through its normal supervisor. Do not delete the
+conversation history or evidence vault. Verify the imported package path,
+loaded version and current-model request metrics, not only a UI percentage.
+
+## Validation and scope
+
+The formatted PR candidate passed Python 3.10–3.13, Windows, packaging, Rust,
+learned-policy, ContextEngine, dashboard and Context IR workflows before release
+preparation. The learning artifact reported 611 passed and three skips; one
+skip is the whole optional real-Hermes test module. Its nine cases were previously
+run locally against installed Hermes `188a1a5` on Python 3.14, not counted as
+executed in that learning job. Final release-head CI status is recorded on PR #20.
+No paid inference or new performance/answer-quality claims accompany this patch.
+
+**Not fixed by this patch:** unknown-model tokenizer support and the separate
+host plugin-discovery race. Without an exact target tokenizer, ContextEngine/IR
+still pass through. This is not a guarantee that an affected live session has
+shrunk, and it does not merge the separate deployed-adapter branch.
+
+README changes are limited to a compatibility note, current install/version
+references and correcting the stale FAQ: explicitly selected TT can replace LCM
+as Hermes' ContextEngine. Artwork and schematics are unchanged.
+
+The Rust interoperability companion is version-aligned to 0.11.1 without a Rust
+behavior change; it is not the Python runtime. GitHub assets include the wheel,
+source distribution and SHA256SUMS. Existing releases remain unchanged.
+Publication does not install, configure or restart your running agents.
+
+[PR #20](https://github.com/AronAxe/Token-Terminator/pull/20) ·
+[Compatibility guide](https://github.com/AronAxe/Token-Terminator/blob/v0.11.1/docs/LIVE_COMPRESSION_COMPAT.md) ·
+[Migration](https://github.com/AronAxe/Token-Terminator/blob/v0.11.1/MIGRATION.md)
diff --git a/pyproject.toml b/pyproject.toml
index 82bd8a7..fa38af8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "token-terminator"
-version = "0.11.0"
+version = "0.11.1"
description = "Fail-open token reduction, exact recovery, and request compilation for Hermes Agent"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/rtk_hermes_plus/_version.py b/src/rtk_hermes_plus/_version.py
index ae6db5f..fee46bd 100644
--- a/src/rtk_hermes_plus/_version.py
+++ b/src/rtk_hermes_plus/_version.py
@@ -1 +1 @@
-__version__ = "0.11.0"
+__version__ = "0.11.1"
diff --git a/src/rtk_hermes_plus/hermes_engine.py b/src/rtk_hermes_plus/hermes_engine.py
index 5c28f1b..7159183 100644
--- a/src/rtk_hermes_plus/hermes_engine.py
+++ b/src/rtk_hermes_plus/hermes_engine.py
@@ -1,4 +1,4 @@
-"""Optional Hermes adapter. Normal TT imports never depend on Hermes being installed."""
+"""Hermes adapter with live-config and route-local model-window compatibility."""
from agent.context_engine import ContextEngine
@@ -6,8 +6,127 @@
class TokenTerminatorContextEngine(HistoryContextEngine, ContextEngine):
- """Selectable through Hermes' supported user ContextEngine directory mechanism."""
+ """Hermes updates its selected engine through context_compressor as well."""
+
+ def __init__(self, **kwargs):
+ self.threshold_tokens_cap = None
+ self._config_context_length = None
+ self._model_metadata_kwargs = {}
+ self._resolved_context_length = 0
+ self._threshold_tokens = self._tail_token_budget = None
+ super().__init__(**kwargs)
@property
def name(self) -> str:
return "token-terminator"
+
+ @staticmethod
+ def _coerce_threshold_tokens_cap(value) -> int | None:
+ """Positive integer or None, as required by Hermes' live-config hook."""
+ try:
+ cap = int(value) if value is not None else 0
+ except (TypeError, ValueError, OverflowError):
+ return None
+ return cap if cap > 0 else None
+
+ @staticmethod
+ def _effective_threshold_percent(context_length, threshold_percent):
+ from agent.context_compressor import ContextCompressor
+
+ return ContextCompressor._effective_threshold_percent(
+ context_length, threshold_percent
+ )
+
+ @property
+ def context_length(self) -> int:
+ if self._resolved_context_length is None:
+ from agent.model_metadata import get_model_context_length
+
+ self._resolved_context_length = get_model_context_length(
+ self.model,
+ config_context_length=self._config_context_length,
+ **self._model_metadata_kwargs,
+ )
+ return self._resolved_context_length
+
+ @context_length.setter
+ def context_length(self, value: int) -> None:
+ self._resolved_context_length = max(0, int(value))
+ self._threshold_tokens = self._tail_token_budget = None
+
+ def _effective_threshold_cap(self, context_length: int) -> int | None:
+ cap = self._coerce_threshold_tokens_cap(self.threshold_tokens_cap)
+ return min(cap, context_length) if cap is not None else None
+
+ @property
+ def threshold_tokens(self) -> int:
+ if self._threshold_tokens is None:
+ window = self.context_length
+ base = getattr(self, "_base_threshold_percent", self.threshold_percent)
+ self.threshold_percent = self._effective_threshold_percent(window, base)
+ threshold = int(window * self.threshold_percent)
+ cap = self._effective_threshold_cap(window)
+ self._threshold_tokens = (
+ min(threshold, cap) if cap is not None else threshold
+ )
+ return self._threshold_tokens
+
+ @threshold_tokens.setter
+ def threshold_tokens(self, value: int) -> None:
+ self._threshold_tokens = value
+
+ def update_model(self, model: str, context_length: int, **kwargs) -> None:
+ from agent.context_compressor import resolve_model_threshold
+
+ with self._lock:
+ self._model_metadata_kwargs = {
+ key: kwargs[key]
+ for key in ("base_url", "api_key", "provider", "custom_providers")
+ if key in kwargs
+ }
+ if not hasattr(self, "_config_threshold_percent"):
+ self._config_threshold_percent = self.threshold_percent
+ self._base_threshold_percent = resolve_model_threshold(
+ model,
+ getattr(self, "model_thresholds", {}),
+ self._config_threshold_percent,
+ kwargs.get("provider", ""),
+ )
+ self.threshold_percent = self._effective_threshold_percent(
+ context_length, self._base_threshold_percent
+ )
+ super().update_model(model, context_length, **kwargs)
+ self._threshold_tokens = self._tail_token_budget = None
+
+ def select_context(self, request_messages, **kwargs):
+ from .call_scope import internal_operation
+
+ if internal_operation():
+ return None
+ with self._lock:
+ # The hook carries the host's current route window, not a fixed TT cap.
+ # Do not use update_model here: that would reset retry/usage state.
+ budget = kwargs.get("budget_tokens", 0)
+ if type(budget) is int and budget > 0:
+ self.context_length = budget
+ return super().select_context(request_messages, **kwargs)
+
+ def clone_for_agent(self):
+ with self._lock:
+ clone = type(self)(config=self.config, limits=self.limits)
+ clone.threshold_percent = self.threshold_percent
+ clone.threshold_tokens_cap = self.threshold_tokens_cap
+ clone._config_context_length = self._config_context_length
+ for key in ("_config_threshold_percent", "_base_threshold_percent"):
+ if hasattr(self, key):
+ setattr(clone, key, getattr(self, key))
+ clone.model_thresholds = dict(getattr(self, "model_thresholds", {}))
+ clone.update_model(
+ self.model, self.context_length, **self._model_metadata_kwargs
+ )
+ return clone
+
+ def get_status(self):
+ result = super().get_status()
+ result["adapter_compat"] = "hermes-live-compression-v1"
+ return result
diff --git a/tests/test_hermes_live_compression.py b/tests/test_hermes_live_compression.py
new file mode 100644
index 0000000..7387aba
--- /dev/null
+++ b/tests/test_hermes_live_compression.py
@@ -0,0 +1,124 @@
+"""Regression against the real optional Hermes live-config implementation."""
+
+import copy
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+try:
+ from tui_gateway.session_compression import _apply_live_compression_config
+except ImportError as exc:
+ raise unittest.SkipTest("requires a current Hermes checkout") from exc
+
+from rtk_hermes_plus.config import Config
+from rtk_hermes_plus.hermes_engine import TokenTerminatorContextEngine as Engine
+from rtk_hermes_plus.history_engine import HistoryContextEngine
+
+
+class LiveConfigTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.engine = Engine(
+ config=Config(
+ db_path=Path(self.tmp.name) / "vault.db", ledger_enabled=False
+ )
+ )
+ self.engine.update_model("fixture-main", 272000, provider="fixture")
+ self.agent = types.SimpleNamespace(
+ context_compressor=self.engine, model="fixture-main", provider="fixture"
+ )
+
+ def apply(self, cfg, pin=None):
+ with patch(
+ "agent.agent_init.config_context_length_for_runtime", return_value=pin
+ ):
+ _apply_live_compression_config(self.agent, cfg)
+
+ def test_cap_values(self):
+ cases = [
+ (None, None),
+ (0, None),
+ (-1, None),
+ ("bad", None),
+ ("", None),
+ (180000, 180000),
+ ("180000", 180000),
+ (float("inf"), None),
+ ]
+ for value, expected in cases:
+ with self.subTest(value=value):
+ self.assertEqual(
+ self.engine._coerce_threshold_tokens_cap(value), expected
+ )
+
+ def test_actual_live_hook_updates_pin_and_cap(self):
+ self.apply(
+ {"compression": {"threshold": 0.7, "threshold_tokens": 180000}},
+ pin=1000000,
+ )
+ self.assertEqual(self.engine.context_length, 1000000)
+ self.assertEqual(self.engine.threshold_tokens, 180000)
+ self.assertEqual(self.engine._config_context_length, 1000000)
+
+ def test_removed_pin_reresolves_current_route(self):
+ self.apply(
+ {"compression": {"threshold": 0.7, "threshold_tokens": 180000}},
+ pin=1000000,
+ )
+ with patch(
+ "agent.model_metadata.get_model_context_length", return_value=600000
+ ) as resolver:
+ self.apply({"compression": {"threshold": 0.7, "threshold_tokens": None}})
+ self.assertIsNone(self.engine._config_context_length)
+ self.assertEqual(self.engine.context_length, 600000)
+ self.assertEqual(self.engine.threshold_tokens, 420000)
+ self.assertEqual(resolver.call_args.args[0], "fixture-main")
+ self.assertEqual(resolver.call_args.kwargs["provider"], "fixture")
+
+ def test_live_small_window_threshold_floor(self):
+ self.apply({"compression": {"threshold": 0.1, "threshold_tokens": None}})
+ self.assertEqual(self.engine.threshold_tokens, 204000)
+
+ def test_model_switch_preserves_cap_and_changes_route(self):
+ self.engine.threshold_tokens_cap = 180000
+ self.engine._threshold_tokens = None
+ self.engine.update_model("fixture-small", 32000, provider="other")
+ self.assertEqual(self.engine.threshold_tokens, 24000)
+ self.assertEqual(self.engine._model_metadata_kwargs, {"provider": "other"})
+
+ def test_host_request_window_replaces_stale_272k(self):
+ self.engine.last_prompt_tokens = 270000
+ messages = [{"role": "user", "content": "Keep this exact."}]
+ original = copy.deepcopy(messages)
+ with patch.object(
+ HistoryContextEngine, "select_context", return_value=None
+ ) as select:
+ self.engine.select_context(messages, budget_tokens=1000000)
+ select.assert_called_once()
+ self.assertEqual(self.engine.context_length, 1000000)
+ self.assertEqual(self.engine.last_prompt_tokens, 270000)
+ self.assertEqual(messages, original)
+
+ def test_unknown_window_does_not_erase_known_window(self):
+ with patch.object(HistoryContextEngine, "select_context", return_value=None):
+ self.engine.select_context([], budget_tokens=0)
+ self.assertEqual(self.engine.context_length, 272000)
+
+ def test_clones_do_not_share_mutable_budget_state(self):
+ self.engine.threshold_tokens_cap = 100000
+ self.engine.model_thresholds = {"fixture": 0.6}
+ other = self.engine.clone_for_agent()
+ other.threshold_tokens_cap = 50000
+ other.model_thresholds["fixture"] = 0.9
+ other.context_length = 1000000
+ self.assertEqual(self.engine.threshold_tokens_cap, 100000)
+ self.assertEqual(self.engine.model_thresholds, {"fixture": 0.6})
+ self.assertEqual(self.engine.context_length, 272000)
+
+ def test_no_summarizer_or_destructive_compression(self):
+ self.assertFalse(self.engine.should_compress(9999999))
+ self.assertFalse(self.engine.should_compress_preflight([]))
+ self.assertIsNone(self.engine._catalog)