From 0780ca8166e82a421e8c7c545ac586d2dc869a49 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 13 May 2026 20:40:56 -0700 Subject: [PATCH] Add live chat runtime --- chat/__init__.py | 4 +- chat/__main__.py | 39 +++++++++++ chat/runtime.py | 133 +++++++++++++++++++++++++++++++++---- probe/repl.py | 36 ++++------ tests/test_chat_runtime.py | 23 +++++++ 5 files changed, 198 insertions(+), 37 deletions(-) create mode 100644 chat/__main__.py create mode 100644 tests/test_chat_runtime.py diff --git a/chat/__init__.py b/chat/__init__.py index a400e87f..d064f61d 100644 --- a/chat/__init__.py +++ b/chat/__init__.py @@ -1,3 +1,3 @@ -from .runtime import ChatRuntime +from .runtime import ChatResponse, ChatRuntime -__all__ = ["ChatRuntime"] +__all__ = ["ChatResponse", "ChatRuntime"] diff --git a/chat/__main__.py b/chat/__main__.py new file mode 100644 index 00000000..af194f3d --- /dev/null +++ b/chat/__main__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +try: + import readline # noqa: F401 +except ImportError: # pragma: no cover - platform optional + readline = None + +from chat.runtime import ChatRuntime + +_DIM = "\033[2m" +_RESET = "\033[0m" + + +def main() -> None: + runtime = ChatRuntime() + while True: + try: + text = input("> ").strip() + except EOFError: + print() + break + if text in {"quit", "exit"}: + break + if not text: + continue + try: + response = runtime.chat(text) + except (KeyError, ValueError) as exc: + print(f"{_DIM}[{exc}]{_RESET}") + continue + print(response.surface) + print( + f"{_DIM}[role={response.dialogue_role} " + f"versor_condition={response.versor_condition:.2e}]{_RESET}" + ) + + +if __name__ == "__main__": + main() diff --git a/chat/runtime.py b/chat/runtime.py index 4792b068..ebfa56d2 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -1,25 +1,82 @@ from __future__ import annotations +from dataclasses import dataclass import re +from collections.abc import Sequence -from language_packs import OOVPolicy, load_pack, load_pack_entries +import numpy as np + +from algebra.versor import versor_condition +from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue +from generate.proposition import FrameRegistry, Proposition, propose +from generate.stream import generate +from language_packs import OOVPolicy, load_mounted_packs, load_pack, load_pack_entries from persona.motor import PersonaMotor from session.context import SessionContext _TOKEN_RE = re.compile(r"\w+", re.UNICODE) +_DEFAULT_PACKS = ("en_minimal_v1", "he_logos_micro_v1", "grc_logos_micro_v1") +_SEED_ALIASES = { + "logos": "λόγος", + "dabar": "דבר", + "or": "אור", + "phos": "φῶς", + "zoe": "ζωή", + "arche": "ἀρχή", + "aletheia": "ἀλήθεια", +} + + +@dataclass(frozen=True, slots=True) +class ChatResponse: + surface: str + proposition: Proposition + dialogue_role: DialogueRole + versor_condition: float class ChatRuntime: - def __init__(self, pack_id: str = "en_minimal_v1") -> None: - manifest, manifold = load_pack(pack_id) - entries = load_pack_entries(pack_id) - self._manifest = manifest + def __init__( + self, + pack_id: str | Sequence[str] = _DEFAULT_PACKS, + *, + frame_pack: str | None = None, + ) -> None: + pack_ids = (pack_id,) if isinstance(pack_id, str) else tuple(pack_id) + manifests = [] + manifolds = [] + entries = [] + for mounted_pack_id in pack_ids: + manifest, manifold = load_pack(mounted_pack_id) + manifests.append(manifest) + manifolds.append(manifold) + entries.extend(load_pack_entries(mounted_pack_id)) + + manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids) + self._manifests = tuple(manifests) self._context = SessionContext(manifold, persona=PersonaMotor.identity()) + self._frame_registry = FrameRegistry.from_pack( + frame_pack or self._default_frame_pack(pack_ids), + self._context.vocab, + ) self._surface_by_fold = {e.surface.casefold(): e.surface for e in entries} + self._surface_by_fold.update(_SEED_ALIASES) self._pos_by_surface = { e.surface: (e.pos or e.part_of_speech or "X") for e in entries } + @property + def session(self) -> SessionContext: + return self._context + + @staticmethod + def _default_frame_pack(pack_ids: tuple[str, ...]) -> str: + if any(pack_id.startswith("grc_") for pack_id in pack_ids): + return "grc" + if any(pack_id.startswith("he_") for pack_id in pack_ids): + return "he" + return "en" + def _tokenize(self, text: str) -> list[str]: tokens: list[str] = [] for match in _TOKEN_RE.finditer(text): @@ -27,6 +84,9 @@ class ChatRuntime: tokens.append(self._surface_by_fold.get(raw.casefold(), raw)) return tokens + def tokenize(self, text: str) -> list[str]: + return self._tokenize(text) + def _apply_oov_policy(self, tokens: list[str]) -> list[str]: kept: list[str] = [] for token in tokens: @@ -34,9 +94,12 @@ class ChatRuntime: self._context.vocab.get_versor(token) kept.append(token) except KeyError: - if self._manifest.oov_policy is OOVPolicy.FAIL_CLOSED: + if all(manifest.oov_policy is OOVPolicy.FAIL_CLOSED for manifest in self._manifests): raise - if self._manifest.oov_policy is OOVPolicy.PROPOSE_VOCAB_EXPANSION: + if any( + manifest.oov_policy is OOVPolicy.PROPOSE_VOCAB_EXPANSION + for manifest in self._manifests + ): raise KeyError(f"OOV token requires vocab proposal: {token}") return kept @@ -51,12 +114,58 @@ class ChatRuntime: prev_pos = pos return out - def respond(self, text: str, max_tokens: int = 32) -> str: + def _dialogue_reference(self) -> np.ndarray | None: + blade = self._context.last_dialogue_blade + if blade is None or float(np.linalg.norm(blade)) < 1e-8: + return None + return blade + + def chat(self, text: str, max_tokens: int = 32) -> ChatResponse: tokens = self._tokenize(text) filtered = self._apply_oov_policy(tokens) if not filtered: - return "" - self._context.ingest(filtered) - result = self._context.respond(max_tokens=max_tokens) + raise ValueError("ChatRuntime.chat() received no in-vocabulary tokens.") + + field_state = self._context.ingest(filtered) + reference_blade = self._dialogue_reference() + base_proposition = propose(field_state, None, self._context.vocab, self._frame_registry) + dialogue_role = classify_dialogue_blade( + base_proposition.relation, + reference_blade, + ) + proposition = propose_dialogue( + field_state, + None, + self._context.vocab, + self._frame_registry, + reference_blade, + ) + self._context.record_dialogue(proposition) + + result = generate( + field_state, + self._context.vocab, + self._context.persona, + max_tokens=max_tokens, + vault=None, + ) + self._context.state = result.final_state + self._context.vault.store( + result.final_state.F, + {"turn": self._context.turn, "role": "assistant"}, + ) + self._context.turn += 1 guarded = self._syntactic_guard(result.tokens) - return " ".join(guarded) + surface = " ".join(guarded) + return ChatResponse( + surface=surface, + proposition=proposition, + dialogue_role=dialogue_role, + versor_condition=versor_condition(result.final_state.F), + ) + + def respond(self, text: str, max_tokens: int = 32) -> str: + try: + return self.chat(text, max_tokens=max_tokens).surface + except ValueError: + return "" diff --git a/probe/repl.py b/probe/repl.py index 52ea6928..15ff5f52 100644 --- a/probe/repl.py +++ b/probe/repl.py @@ -1,30 +1,20 @@ from __future__ import annotations -from language_packs import load_mounted_packs - -_TRILINGUAL_PACKS = ("en_minimal_v1", "he_logos_micro_v1", "grc_logos_micro_v1") -_SEED_ALIASES = { - "logos": "λόγος", - "dabar": "דבר", - "or": "אור", - "phos": "φῶς", - "zoe": "ζωή", - "arche": "ἀρχή", - "aletheia": "ἀλήθεια", -} +from chat.runtime import ChatRuntime def field_walk(seed: str, steps: int = 4) -> list[str]: - vocab = load_mounted_packs(_TRILINGUAL_PACKS) - surface = _SEED_ALIASES.get(seed.casefold(), seed) - F = vocab.get_versor(surface) - walk = [seed] if surface == seed else [seed, surface] - idx = vocab.index_of(surface) - for _ in range(max(0, steps - len(walk))): - word, idx = vocab.nearest(F, exclude_idx=idx) - walk.append(word) - F = vocab.get_versor(word) - return walk + runtime = ChatRuntime() + injected_terms = runtime.tokenize(seed) + response = runtime.chat(seed, max_tokens=max(1, steps)) + proposition_terms = [ + response.proposition.subject, + response.proposition.predicate, + ] + if response.proposition.object_ is not None: + proposition_terms.append(response.proposition.object_) + walk = [seed, *injected_terms, *proposition_terms, *response.surface.split()] + return walk[: max(1, steps)] def main() -> None: @@ -37,7 +27,7 @@ def main() -> None: break try: chain = field_walk(text) - print(f"[field walk: {' → '.join(chain)}]") + print(f"[field walk: {' -> '.join(chain)}]") except KeyError: print("[unknown token]") diff --git a/tests/test_chat_runtime.py b/tests/test_chat_runtime.py new file mode 100644 index 00000000..dfb947b8 --- /dev/null +++ b/tests/test_chat_runtime.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import numpy as np + +from chat.runtime import ChatResponse, ChatRuntime + + +def test_chat_runtime_keeps_live_session_across_two_turns() -> None: + runtime = ChatRuntime() + + first = runtime.chat("light logos", max_tokens=8) + first_field = runtime.session.state.F.copy() + + second = runtime.chat("light truth", max_tokens=8) + second_field = runtime.session.state.F.copy() + + assert isinstance(first, ChatResponse) + assert first.surface.strip() + assert second.surface.strip() + assert first.versor_condition < 1e-6 + assert second.versor_condition < 1e-6 + assert second.dialogue_role in {"elaborate", "assert"} + assert not np.array_equal(second_field, first_field)