diff --git a/algebra/versor.py b/algebra/versor.py index e5d557ae..51f92df3 100644 --- a/algebra/versor.py +++ b/algebra/versor.py @@ -14,6 +14,7 @@ _CONSTRUCTION_RESIDUE_TOLERANCE = 1e-2 _NEAR_ZERO_TOLERANCE = 1e-12 _DENSE_SEED_MIN_COMPONENTS = 8 _SEED_BIVECTORS = (6, 7, 8, 10, 11, 13) +_NULL_SCALAR_TOLERANCE = 1e-9 def _array_dtype(v: np.ndarray) -> np.dtype: @@ -95,18 +96,25 @@ def construction_seed_versor(v: np.ndarray) -> np.ndarray: -def _close_applied_versor(v: np.ndarray, dtype: np.dtype) -> np.ndarray: - """Close an algebra-produced sandwich result at the algebra boundary. +def _is_null_vector(v: np.ndarray) -> bool: + product = geometric_product(v, v).astype(np.float64) + return float(np.linalg.norm(product)) < _NULL_SCALAR_TOLERANCE - Generation, propagation, and vault recall are forbidden from normalizing - results. The algebra sandwich operator is the single place that owns this - closure because it is where numerical drift or table-level operator drift - becomes observable. + +def _close_applied_versor(v: np.ndarray, dtype: np.dtype) -> np.ndarray: + """Close algebra-produced sandwich results without breaking null vectors. + + CGA sandwiching must preserve null vectors as null vectors. Unit-versor + closure only applies when the result is meant to remain a versor field; + null vectors are geometric points and must pass through unchanged. """ + arr = np.asarray(v, dtype=dtype) + if _is_null_vector(arr): + return arr.astype(dtype) try: - return unitize_versor(v).astype(dtype) + return unitize_versor(arr).astype(dtype) except ValueError: - return construction_seed_versor(v).astype(dtype) + return construction_seed_versor(arr).astype(dtype) def versor_apply(V: np.ndarray, F: np.ndarray) -> np.ndarray: diff --git a/chat/runtime.py b/chat/runtime.py index cd23b7ed..e0516425 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -63,7 +63,7 @@ def _is_question_input(raw_text: str, tokens: Sequence[str]) -> bool: def _stable_dialogue_role(role: DialogueRole, *, raw_text: str, tokens: Sequence[str]) -> DialogueRole: - if role == "question" and not _is_question_input(raw_text, tokens): + if role in {"question", "refute"} and not _is_question_input(raw_text, tokens): return "elaborate" return role @@ -83,6 +83,38 @@ def _terminate_surface(surface: str, *, role: DialogueRole, output_language: str return f"{stripped}{_terminal_for_role(role, output_language)}" +def _prefer_prompt_anchor( + articulation: ArticulationPlan, + filtered_tokens: Sequence[str], + *, + output_language: str, +) -> ArticulationPlan: + """Keep minimal English question responses sensitive to prompt target. + + The current micro-pack can collapse multiple questions onto the same + nearest proposition slots. Until PropositionGraph lands, preserve a direct + lexical anchor for English question answers so distinct prompts do not + produce identical surfaces. + """ + if output_language != "en" or len(filtered_tokens) < 2: + return articulation + content_tokens = [ + token + for token in filtered_tokens + if token.casefold() not in _QUESTION_WORDS and token.casefold() not in {"is", "are", "was", "were"} + ] + if not content_tokens: + return articulation + anchor = content_tokens[-1] + if anchor == articulation.subject: + return articulation + return replace( + articulation, + subject=anchor, + surface=" ".join(part for part in (anchor, articulation.predicate, articulation.object) if part), + ) + + @dataclass class _StubBindingFrame: frame_id: str @@ -319,6 +351,11 @@ class ChatRuntime: self._context.vocab, output_language=self.config.output_language, ) + articulation = _prefer_prompt_anchor( + articulation, + filtered, + output_language=self.config.output_language, + ) self._context.record_dialogue(proposition) result = generate(