diff --git a/chat/runtime.py b/chat/runtime.py index e802113a..c0b0fb4c 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -38,6 +38,8 @@ class ChatResponse: output_language: str frame_pack: str walk_surface: str + salience_top_k: int | None + candidates_used: int | None class ChatRuntime: @@ -57,6 +59,10 @@ class ChatRuntime: 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, ) else: resolved_config = config @@ -74,7 +80,11 @@ class ChatRuntime: 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._context = SessionContext( + manifold, + persona=PersonaMotor.identity(), + vault_reproject_interval=resolved_config.vault_reproject_interval, + ) self._frame_registry = FrameRegistry.from_pack( resolved_config.frame_pack, self._context.vocab, @@ -176,6 +186,9 @@ class ChatRuntime: recall_top_k=3 if self.config.allow_cross_language_recall else 0, output_lang=self.config.output_language, allow_cross_language_generation=self.config.allow_cross_language_generation, + use_salience=self.config.use_salience, + salience_top_k=self.config.salience_top_k, + inhibition_threshold=self.config.inhibition_threshold, ) self._context.state = result.final_state self._context.vault.store( @@ -194,6 +207,8 @@ class ChatRuntime: output_language=self.config.output_language, frame_pack=self.config.frame_pack, walk_surface=walk_surface, + salience_top_k=result.salience_top_k, + candidates_used=result.candidates_used, ) def respond(self, text: str, max_tokens: int | None = None) -> str: diff --git a/core/cli.py b/core/cli.py index 069b307f..c8e091d5 100644 --- a/core/cli.py +++ b/core/cli.py @@ -58,6 +58,10 @@ def _runtime_config_from_args(args: argparse.Namespace): max_tokens=args.max_tokens, allow_cross_language_recall=not args.no_cross_language_recall, allow_cross_language_generation=args.allow_cross_language_generation, + vault_reproject_interval=args.vault_reproject_interval, + use_salience=not args.no_salience, + salience_top_k=args.salience_top_k, + inhibition_threshold=args.inhibition_threshold, ) @@ -135,6 +139,7 @@ def _runtime_for_trace(args: argparse.Namespace): def _trace_payload(text: str, resp: Any, runtime: Any) -> dict[str, Any]: proposition = resp.proposition articulation = resp.articulation + vault = runtime.session.vault payload: dict[str, Any] = { "input": text, "surface": resp.surface, @@ -143,6 +148,8 @@ def _trace_payload(text: str, resp: Any, runtime: Any) -> dict[str, Any]: "frame_pack": resp.frame_pack, "dialogue_role": str(resp.dialogue_role), "versor_condition": float(resp.versor_condition), + "salience_top_k": resp.salience_top_k, + "candidates_used": resp.candidates_used, "articulation": { "surface": articulation.surface, "frame_id": articulation.frame_id, @@ -159,7 +166,9 @@ def _trace_payload(text: str, resp: Any, runtime: Any) -> dict[str, Any]: "object": proposition.object_, "relation_norm": proposition.relation_norm, }, - "vault_entries": len(runtime.session.vault), + "vault_entries": len(vault), + "vault_reproject_every": vault.reproject_interval, + "vault_store_count": vault.store_count, "oov_grounded": list(getattr(runtime.session.vocab, "unknown_token_log", [])), } return payload @@ -171,6 +180,8 @@ def _print_trace(payload: dict[str, Any]) -> None: print(f"raw_walk : {payload['walk_surface']}") print(f"output_language: {payload['output_language']}") print(f"frame_pack : {payload['frame_pack']}") + print(f"salience_top_k : {payload['salience_top_k']}") + print(f"candidates_used: {payload['candidates_used']}") print(f"dialogue_role : {payload['dialogue_role']}") print(f"versor_cond : {payload['versor_condition']:.2e}") articulation = payload["articulation"] @@ -188,6 +199,8 @@ def _print_trace(payload: dict[str, Any]) -> None: print(f" object : {proposition['object']!r}") print(f" relation_norm: {proposition['relation_norm']:.4f}") print(f"vault_entries : {payload['vault_entries']}") + print(f"vault_reproject_every: {payload['vault_reproject_every']}") + print(f"vault_store_count : {payload['vault_store_count']}") oov_entries = payload["oov_grounded"] if oov_entries: print(f"oov_grounded : {len(oov_entries)} token(s)") @@ -361,6 +374,10 @@ def _add_runtime_policy_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--output-language", default="en", help="target output language code; default: en") parser.add_argument("--frame-pack", help="frame pack to use; defaults to output language") parser.add_argument("--max-tokens", type=int, default=32, help="maximum generated tokens; default: 32") + parser.add_argument("--vault-reproject-interval", type=int, default=20, help="vault null-cone reprojection cadence; default: 20 stores") + parser.add_argument("--salience-top-k", type=int, default=16, help="salience candidate budget; default: 16") + parser.add_argument("--inhibition-threshold", type=float, default=0.3, help="attention inhibition threshold; default: 0.3") + parser.add_argument("--no-salience", action="store_true", help="disable salience attention and use full-manifold generation") parser.add_argument( "--allow-cross-language-generation", action="store_true", diff --git a/core/config.py b/core/config.py index 342220ed..39958510 100644 --- a/core/config.py +++ b/core/config.py @@ -11,6 +11,10 @@ class RuntimeConfig: max_tokens: int = 32 allow_cross_language_recall: bool = True allow_cross_language_generation: bool = False + vault_reproject_interval: int = 20 + use_salience: bool = True + salience_top_k: int = 16 + inhibition_threshold: float = 0.3 DEFAULT_CONFIG = RuntimeConfig() diff --git a/generate/attention.py b/generate/attention.py new file mode 100644 index 00000000..315b2f6a --- /dev/null +++ b/generate/attention.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from generate.salience import SalienceMap +from vocab.manifold import VocabManifold + + +@dataclass(frozen=True, slots=True) +class AttentionPlan: + allowed_indices: np.ndarray + salience_map: SalienceMap + + def __post_init__(self) -> None: + object.__setattr__(self, "allowed_indices", np.asarray(self.allowed_indices, dtype=np.int64).copy()) + + +class AttentionOperator: + """ + Convert SalienceMap to AttentionPlan by applying budget and inhibition. + + Inhibition excludes indices whose score is below max_score * threshold, + removing the weak long-tail of manifold points before generation walks. + """ + + def __init__(self, inhibition_threshold: float = 0.3) -> None: + if inhibition_threshold < 0.0: + raise ValueError("inhibition_threshold must be non-negative") + self.inhibition_threshold = float(inhibition_threshold) + + def plan(self, salience: SalienceMap, vocab: VocabManifold) -> AttentionPlan: + if len(salience.indices) == 0: + return AttentionPlan(allowed_indices=np.asarray([], dtype=np.int64), salience_map=salience) + max_score = float(salience.scores[0]) + threshold = max_score * self.inhibition_threshold + mask = salience.scores >= threshold + allowed = salience.indices[mask] + if len(allowed) == 0: + allowed = salience.indices[:1] + allowed = allowed[: min(len(allowed), salience.budget, len(vocab))] + return AttentionPlan(allowed_indices=allowed, salience_map=salience) diff --git a/generate/result.py b/generate/result.py index 453a6f56..ddc24199 100644 --- a/generate/result.py +++ b/generate/result.py @@ -14,7 +14,7 @@ Contracts: """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from field.state import FieldState @@ -23,6 +23,8 @@ class GenerationResult: tokens: tuple # decoded token sequence, immutable final_state: FieldState trajectory: tuple | None = None # (FieldState, ...) or None + salience_top_k: int | None = None + candidates_used: int | None = None def __post_init__(self) -> None: # Coerce list inputs to tuple for immutability. diff --git a/generate/salience.py b/generate/salience.py new file mode 100644 index 00000000..e755093e --- /dev/null +++ b/generate/salience.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from algebra.backend import cga_inner +from field.state import FieldState +from vocab.manifold import VocabManifold + + +@dataclass(frozen=True, slots=True) +class SalienceMap: + indices: np.ndarray + scores: np.ndarray + budget: int + + def __post_init__(self) -> None: + object.__setattr__(self, "indices", np.asarray(self.indices, dtype=np.int64).copy()) + object.__setattr__(self, "scores", np.asarray(self.scores, dtype=np.float32).copy()) + object.__setattr__(self, "budget", int(self.budget)) + + +class SalienceOperator: + """ + Compute geometric salience of manifold points relative to current FieldState. + + Salience is field-relative CGA activation: + salience(v_i) = |cga_inner(F, v_i)| / (||F|| * ||v_i||) + + No learned weights. No softmax. Pure geometry routed through algebra.backend, + which uses core_rs when active. + """ + + def compute(self, field: FieldState, vocab: VocabManifold, top_k: int = 16) -> SalienceMap: + if top_k <= 0: + return SalienceMap(indices=np.asarray([], dtype=np.int64), scores=np.asarray([], dtype=np.float32), budget=0) + if len(vocab) == 0: + return SalienceMap(indices=np.asarray([], dtype=np.int64), scores=np.asarray([], dtype=np.float32), budget=0) + + query = np.asarray(field.F, dtype=np.float32) + query_norm = max(float(np.linalg.norm(query)), 1e-8) + scores: list[float] = [] + for idx in range(len(vocab)): + v = vocab.get_versor_at(idx) + denom = query_norm * max(float(np.linalg.norm(v)), 1e-8) + scores.append(abs(float(cga_inner(query, v))) / denom) + + scores_arr = np.asarray(scores, dtype=np.float32) + k = min(int(top_k), len(vocab)) + order = np.argsort(-scores_arr, kind="stable")[:k] + return SalienceMap(indices=order.astype(np.int64), scores=scores_arr[order], budget=k) diff --git a/generate/stream.py b/generate/stream.py index 963b47d0..61930062 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -23,7 +23,9 @@ import numpy as np from field.state import FieldState from field.propagate import propagate_step from algebra.rotor import word_transition_rotor +from generate.attention import AttentionOperator from generate.result import GenerationResult +from generate.salience import SalienceOperator _RECENT_WINDOW = 3 _STOP_TOKENS = frozenset({"it", "to", "word"}) @@ -60,6 +62,10 @@ def _nearest_next( Recent-node exclusion reduces two- and three-token attractor cycles. Stop-node exclusion keeps function-word wells from dominating when more informative neighbors are available. + + If attention/language filtering leaves only the current node available, + the final fallback deliberately permits that singleton candidate instead + of crashing. That keeps inhibition fail-closed to the attended region. """ if len(vocab) <= 1: return vocab.nearest(F_voiced, candidate_indices=candidate_indices) @@ -82,7 +88,7 @@ def _nearest_next( ) except ValueError: continue - return vocab.nearest(F_voiced, exclude_idx=current_node, candidate_indices=candidate_indices) + return vocab.nearest(F_voiced, candidate_indices=candidate_indices) def _voiced_state(state: FieldState, persona) -> FieldState: @@ -131,6 +137,31 @@ def _candidate_indices_for_language(vocab, output_lang: str | None) -> np.ndarra return indices +def _intersect_candidates(a: np.ndarray | None, b: np.ndarray | None) -> np.ndarray | None: + if a is None: + return b + if b is None: + return a + if len(a) == 0 or len(b) == 0: + return np.asarray([], dtype=np.int64) + b_set = {int(idx) for idx in b} + return np.asarray([int(idx) for idx in a if int(idx) in b_set], dtype=np.int64) + + +def _attention_candidates( + state: FieldState, + vocab, + use_salience: bool, + salience_top_k: int, + inhibition_threshold: float, +) -> tuple[np.ndarray | None, int | None, int | None]: + if not use_salience: + return None, None, None + salience = SalienceOperator().compute(state, vocab, top_k=salience_top_k) + attention = AttentionOperator(inhibition_threshold).plan(salience, vocab) + return attention.allowed_indices, salience.budget, len(attention.allowed_indices) + + def generate( state: FieldState, vocab, @@ -141,6 +172,9 @@ def generate( recall_top_k: int = 3, output_lang: str | None = None, allow_cross_language_generation: bool = True, + use_salience: bool = False, + salience_top_k: int = 16, + inhibition_threshold: float = 0.3, ) -> GenerationResult: """ Generate a token sequence from an initial FieldState. @@ -156,20 +190,34 @@ def generate( 7. Advance node pointer Returns: - GenerationResult with tokens, final_state, and optional trajectory. + GenerationResult with tokens, final_state, optional trajectory, + and salience telemetry when attention is enabled. """ tokens = [] trajectory = [] if record_trajectory else None current = state recent_nodes = deque([state.node], maxlen=_RECENT_WINDOW) - candidate_indices = None if allow_cross_language_generation else _candidate_indices_for_language(vocab, output_lang) + language_candidates = None if allow_cross_language_generation else _candidate_indices_for_language(vocab, output_lang) + salience_candidates, salience_budget, candidates_used = _attention_candidates( + state, + vocab, + use_salience=use_salience, + salience_top_k=salience_top_k, + inhibition_threshold=inhibition_threshold, + ) + candidate_indices = _intersect_candidates(language_candidates, salience_candidates) + if candidate_indices is not None and len(candidate_indices) == 0: + candidate_indices = language_candidates if language_candidates is not None else salience_candidates + candidates_used = None if candidate_indices is None else len(candidate_indices) + stop_nodes = frozenset( vocab.index_of(token) for token in _STOP_TOKENS if token in {vocab.get_word_at(i) for i in range(len(vocab))} ) - for _ in range(max_tokens): + token_budget = min(max_tokens, int(candidates_used)) if candidates_used is not None else max_tokens + for _ in range(token_budget): current = _recall_state(_voiced_state(current, persona), vault, recall_top_k) word, word_idx = _nearest_next( vocab, @@ -196,6 +244,8 @@ def generate( tokens=tokens, final_state=current, trajectory=trajectory, + salience_top_k=salience_budget, + candidates_used=candidates_used, ) diff --git a/session/context.py b/session/context.py index c51d028f..f787edaf 100644 --- a/session/context.py +++ b/session/context.py @@ -27,10 +27,10 @@ from vault.store import VaultStore class SessionContext: - def __init__(self, vocab, persona=None, vault=None): + def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100): self.vocab = vocab self.persona = persona or PersonaMotor.identity() - self.vault = vault or VaultStore() + self.vault = vault or VaultStore(reproject_interval=vault_reproject_interval) self.state: FieldState | None = None self.turn: int = 0 self.dialogue_history: list[DialogueTurn] = [] diff --git a/tests/test_cli.py b/tests/test_cli.py index b4738704..e9fd9478 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,6 +24,8 @@ def test_trace_help_exits_without_runtime_import(capsys: pytest.CaptureFixture[s assert "--pack" in out assert "--output-language" in out assert "--frame-pack" in out + assert "--salience-top-k" in out + assert "--no-salience" in out assert "--json" in out @@ -76,11 +78,15 @@ def test_doctor_rust_reports_backend_state(capsys: pytest.CaptureFixture[str]) - def test_trace_formats_real_runtime_payload(capsys: pytest.CaptureFixture[str]) -> None: - assert main(["trace", "--pack", "en_minimal_v1", "word", "beginning", "truth"]) == 0 + assert main(["trace", "--pack", "en_minimal_v1", "--salience-top-k", "8", "word", "beginning", "truth"]) == 0 out = capsys.readouterr().out assert "input : word beginning truth" in out assert "output_language: en" in out assert "frame_pack : en" in out + assert "salience_top_k : 8" in out + assert "candidates_used:" in out + assert "vault_reproject_every:" in out + assert "vault_store_count" in out assert "articulation" in out assert "raw_walk" in out assert "proposition" in out @@ -89,13 +95,24 @@ def test_trace_formats_real_runtime_payload(capsys: pytest.CaptureFixture[str]) def test_trace_json_formats_real_runtime_payload(capsys: pytest.CaptureFixture[str]) -> None: - assert main(["trace", "--pack", "en_minimal_v1", "--json", "word", "beginning", "truth"]) == 0 + assert main(["trace", "--pack", "en_minimal_v1", "--json", "--salience-top-k", "8", "word", "beginning", "truth"]) == 0 out = capsys.readouterr().out assert '"input": "word beginning truth"' in out assert '"output_language": "en"' in out assert '"frame_pack": "en"' in out + assert '"salience_top_k": 8' in out + assert '"candidates_used"' in out + assert '"vault_reproject_every"' in out + assert '"vault_store_count"' in out assert '"articulation"' in out assert '"walk_surface"' in out assert '"proposition"' in out assert '"subject"' in out assert '"predicate"' in out + + +def test_trace_json_no_salience_has_null_salience_telemetry(capsys: pytest.CaptureFixture[str]) -> None: + assert main(["trace", "--pack", "en_minimal_v1", "--json", "--no-salience", "word", "beginning", "truth"]) == 0 + out = capsys.readouterr().out + assert '"salience_top_k": null' in out + assert '"candidates_used": null' in out diff --git a/tests/test_salience.py b/tests/test_salience.py new file mode 100644 index 00000000..9dcfbb1b --- /dev/null +++ b/tests/test_salience.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import numpy as np + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from generate.attention import AttentionOperator +from generate.salience import SalienceOperator + + +def test_salience_map_has_top_k_entries_and_descending_scores() -> None: + runtime = ChatRuntime(config=RuntimeConfig(output_language="en", frame_pack="en")) + field = runtime.session.ingest(runtime.tokenize("word beginning truth")) + salience = SalienceOperator().compute(field, runtime.session.vocab, top_k=8) + + assert len(salience.indices) == 8 + assert len(salience.scores) == 8 + assert salience.budget == 8 + assert np.all(salience.scores[:-1] >= salience.scores[1:]) + + +def test_attention_plan_inhibits_salience_tail() -> None: + runtime = ChatRuntime(config=RuntimeConfig(output_language="en", frame_pack="en")) + field = runtime.session.ingest(runtime.tokenize("word beginning truth")) + salience = SalienceOperator().compute(field, runtime.session.vocab, top_k=16) + plan = AttentionOperator(inhibition_threshold=0.9).plan(salience, runtime.session.vocab) + + assert 0 < len(plan.allowed_indices) <= len(salience.indices) + assert set(plan.allowed_indices).issubset(set(salience.indices)) + assert len(plan.allowed_indices) < len(salience.indices) + + +def test_salience_enabled_bounds_generation_walk() -> None: + config = RuntimeConfig(output_language="en", frame_pack="en", salience_top_k=8) + runtime = ChatRuntime(config=config) + response = runtime.chat("word beginning truth") + + assert response.salience_top_k == 8 + assert response.candidates_used is not None + assert 0 < response.candidates_used <= 8 + assert len(response.walk_surface.split()) <= response.candidates_used + + +def test_salience_disabled_preserves_full_generation_budget_telemetry() -> None: + config = RuntimeConfig(output_language="en", frame_pack="en", use_salience=False, max_tokens=12) + runtime = ChatRuntime(config=config) + response = runtime.chat("word beginning truth") + + assert response.salience_top_k is None + assert response.candidates_used is None + assert len(response.walk_surface.split()) <= 12 + + +def test_salience_changes_candidate_budget_without_changing_response_contract() -> None: + enabled = ChatRuntime(config=RuntimeConfig(output_language="en", frame_pack="en", salience_top_k=8)) + disabled = ChatRuntime(config=RuntimeConfig(output_language="en", frame_pack="en", use_salience=False, max_tokens=8)) + + salience_response = enabled.chat("word beginning truth") + full_response = disabled.chat("word beginning truth") + + assert salience_response.candidates_used is not None + assert full_response.candidates_used is None + assert salience_response.surface + assert full_response.surface + assert enabled.session.state is not None + assert disabled.session.state is not None diff --git a/tests/test_vault_config.py b/tests/test_vault_config.py new file mode 100644 index 00000000..b3157fba --- /dev/null +++ b/tests/test_vault_config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig + + +def test_runtime_config_controls_vault_reproject_interval_and_store_count() -> None: + runtime = ChatRuntime(config=RuntimeConfig(vault_reproject_interval=5, output_language="en", frame_pack="en")) + + turns = 3 + for text in ("word beginning truth", "light truth word", "begin thought word"): + runtime.chat(text) + + assert runtime.session.vault.reproject_interval == 5 + assert runtime.session.vault.store_count == turns * 3 + assert len(runtime.session.vault) == turns * 3 diff --git a/vault/store.py b/vault/store.py index 8cf3a54e..f2dd00a4 100644 --- a/vault/store.py +++ b/vault/store.py @@ -31,7 +31,7 @@ class VaultStore: self._versors.append(np.asarray(F, dtype=np.float32).copy()) self._metadata.append(metadata or {}) self._store_count += 1 - if self._store_count % self._reproject_interval == 0: + if self._reproject_interval > 0 and self._store_count % self._reproject_interval == 0: self.reproject() return len(self._versors) - 1 @@ -67,5 +67,15 @@ class VaultStore: """ self._versors = [null_project(v) for v in self._versors] + @property + def reproject_interval(self) -> int: + """Return the configured auto-reprojection cadence in store operations.""" + return self._reproject_interval + + @property + def store_count(self) -> int: + """Return how many store() operations have occurred in this vault.""" + return self._store_count + def __len__(self) -> int: return len(self._versors)