Add ADR-0008 salience attention

Add salience and attention operators, wire salience-gated candidate selection into generation, expose vault/salience trace telemetry, and add tests proving non-placeholder salience behavior.
This commit is contained in:
Shay 2026-05-13 22:40:36 -07:00 committed by GitHub
parent df9ced7104
commit aadaf11612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 304 additions and 12 deletions

View file

@ -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:

View file

@ -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",

View file

@ -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()

43
generate/attention.py Normal file
View file

@ -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)

View file

@ -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.

52
generate/salience.py Normal file
View file

@ -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)

View file

@ -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,
)

View file

@ -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] = []

View file

@ -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

66
tests/test_salience.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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)