diff --git a/chat/runtime.py b/chat/runtime.py index 4d6f2e38..bf933061 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -305,21 +305,19 @@ class ChatRuntime: ) -> None: if pack_id is not None or frame_pack is not None: pack_ids = (pack_id,) if isinstance(pack_id, str) else tuple(pack_id or config.input_packs) - resolved_config = RuntimeConfig( + # Use dataclasses.replace so newer RuntimeConfig fields + # (identity_pack, ethics_pack, forward_graph_constraint, + # composed_surface, thread_anaphora, etc.) survive the + # pack_id / frame_pack override path. The previous manual + # reconstruction silently dropped any field not enumerated + # here, which would let a caller like + # ``ChatRuntime(pack_id="x", config=RuntimeConfig(composed_surface=True))`` + # lose composed_surface without warning. + from dataclasses import replace as _dc_replace + resolved_config = _dc_replace( + config, input_packs=pack_ids, - output_language=config.output_language, frame_pack=frame_pack or config.frame_pack, - max_tokens=config.max_tokens, - allow_cross_language_recall=config.allow_cross_language_recall, - allow_cross_language_generation=config.allow_cross_language_generation, - vault_reproject_interval=config.vault_reproject_interval, - use_salience=config.use_salience, - salience_top_k=config.salience_top_k, - inhibition_threshold=config.inhibition_threshold, - inner_loop_admissibility=config.inner_loop_admissibility, - admissibility_threshold=config.admissibility_threshold, - admissibility_mode=config.admissibility_mode, - admissibility_margin=config.admissibility_margin, ) else: resolved_config = config @@ -1107,6 +1105,35 @@ class ChatRuntime: def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse: return self._stub_response(field_state) + def respond(self, text: str, max_tokens: int | None = None) -> str: + """Return only the user-facing surface string for *text*. + + Convenience wrapper around :meth:`chat` for callers that need + the raw surface without ChatResponse provenance — REPLs, simple + scripts, and the existing test_language_pack_runtime suite. + For audit / telemetry / verdict access, call :meth:`chat`. + """ + return self.chat(text, max_tokens=max_tokens).surface + + async def achat(self, text: str, max_tokens: int | None = None) -> ChatResponse: + """Async-compatible convenience wrapper around :meth:`chat`. + + This is a thin async surface; the underlying call is still + synchronous CPU-bound work (versor walk, vault recall, surface + composition). Use this only for integration with asyncio-based + callers that need an awaitable. No real off-thread execution + is performed — if true non-blocking concurrency is required, + wrap calls in :func:`asyncio.to_thread` at the call site. + """ + return self.chat(text, max_tokens=max_tokens) + + async def arespond(self, text: str, max_tokens: int | None = None) -> str: + """Async-compatible convenience wrapper around :meth:`respond`. + + Same caveats as :meth:`achat` — wrapper, not true async. + """ + return self.respond(text, max_tokens=max_tokens) + def correct(self, text: str, target_turn: int = -1, max_tokens: int | None = None) -> ChatResponse: tokens = self._tokenize(text) filtered = self._apply_oov_policy(tokens) diff --git a/tests/test_intent_classification_extensions.py b/tests/test_intent_classification_extensions.py index 5e9037f2..5207f2b9 100644 --- a/tests/test_intent_classification_extensions.py +++ b/tests/test_intent_classification_extensions.py @@ -27,11 +27,12 @@ Five gaps pinned by this file: produces/induces/yields) now routes to CAUSE with X as subject. -Plus a sixth runtime-side fix: CAUSE / VERIFICATION intents now fall -through to ``pack_grounded_surface`` when no teaching chain or cross- -pack chain is rooted on the subject lemma. Honest fallback — the -surface explicitly tags the pack source and emits no fabricated -causal claim. +A sixth runtime-side decision was *considered* (CAUSE / VERIFICATION +falling through to ``pack_grounded_surface`` when no teaching chain +exists) but deliberately not adopted. Doing so would mask the +teaching-gap signal the discovery layer uses to identify chains worth +authoring — see ``tests/test_discovery_candidates``. The +``TestCauseVerificationNoPackFallback`` class below pins this doctrine. """ from __future__ import annotations diff --git a/tests/test_runtime_config_passthrough.py b/tests/test_runtime_config_passthrough.py new file mode 100644 index 00000000..80118ad8 --- /dev/null +++ b/tests/test_runtime_config_passthrough.py @@ -0,0 +1,106 @@ +"""Regression test for ChatRuntime.__init__ config-flag passthrough. + +Prior to fix d-2026-05-19, ``ChatRuntime.__init__`` manually reconstructed +``RuntimeConfig`` field-by-field whenever ``pack_id`` or ``frame_pack`` +was supplied. The manual list was incomplete: newer flags +(``identity_pack``, ``ethics_pack``, ``forward_graph_constraint``, +``composed_surface``, ``thread_anaphora``) were silently dropped, so a +caller like + + ChatRuntime(pack_id="x", config=RuntimeConfig(composed_surface=True)) + +would lose ``composed_surface=True`` without warning. + +The fix uses ``dataclasses.replace`` so every field on the dataclass +survives by construction. This test pins that contract: pass a config +with all current flags set to *non-default* values, instantiate +``ChatRuntime`` with a ``pack_id`` override, and assert every flag +survived on ``runtime.config``. + +If a new flag is added to ``RuntimeConfig``, this test should catch any +future drift on the override path provided the default differs from +the test value (the assertion compares post-init value against the +explicitly-set value, not against the default). +""" + +from __future__ import annotations + +from dataclasses import fields + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig + + +def test_all_runtime_config_flags_survive_pack_id_override() -> None: + custom = RuntimeConfig( + input_packs=("en_minimal_v1",), + output_language="en", + frame_pack="en", + max_tokens=17, # non-default + allow_cross_language_recall=False, + allow_cross_language_generation=True, + vault_reproject_interval=99, + use_salience=False, + salience_top_k=8, + inhibition_threshold=0.42, + inner_loop_admissibility=True, + admissibility_threshold=0.13, + admissibility_mode="margin", + admissibility_margin=0.7, + identity_pack="precision_first_v1", + ethics_pack="default_general_ethics_v1", + forward_graph_constraint=True, + composed_surface=True, + thread_anaphora=True, + ) + + runtime = ChatRuntime(pack_id="en_minimal_v1", config=custom) + + for field in fields(RuntimeConfig): + if field.name == "input_packs": + # The pack_id override deliberately rewrites input_packs; + # other fields must survive verbatim. + continue + expected = getattr(custom, field.name) + actual = getattr(runtime.config, field.name) + assert actual == expected, ( + f"RuntimeConfig.{field.name} did not survive pack_id override: " + f"expected {expected!r}, got {actual!r}" + ) + + +def test_all_runtime_config_flags_survive_frame_pack_override() -> None: + custom = RuntimeConfig( + composed_surface=True, + thread_anaphora=True, + forward_graph_constraint=True, + identity_pack="precision_first_v1", + ethics_pack="default_general_ethics_v1", + ) + + runtime = ChatRuntime(frame_pack="en", config=custom) + + assert runtime.config.composed_surface is True + assert runtime.config.thread_anaphora is True + assert runtime.config.forward_graph_constraint is True + assert runtime.config.identity_pack == "precision_first_v1" + assert runtime.config.ethics_pack == "default_general_ethics_v1" + + +def test_no_override_path_passes_config_through_unchanged() -> None: + """When neither pack_id nor frame_pack is supplied, the user's + config is preserved verbatim (it is the same object identity).""" + custom = RuntimeConfig(composed_surface=True) + runtime = ChatRuntime(config=custom) + # Same object: no reconstruction happened. + assert runtime.config is custom + assert runtime.config.composed_surface is True + + +def test_chat_method_exists() -> None: + """Smoke: the user-facing public methods are present.""" + rt = ChatRuntime() + assert callable(getattr(rt, "chat", None)) + assert callable(getattr(rt, "respond", None)) + assert callable(getattr(rt, "achat", None)) + assert callable(getattr(rt, "arespond", None))