arborist/tests/test_keys.py
russell@unturf.com 6291f7c190
qa: question_hash strip set covers CJK + ellipsis; pin pair-preservation
Per fox: ASCII-only stripping leaves CJK full-width forms behind, so
"who is X?" and "who is X" still produce different cache_keys
despite being the same question. Expanded strip set:

  ASCII:     . ? ! , ; :
  CJK:       ?(U+FF1F)  !(U+FF01)  。(U+3002)  、(U+3001)
  ellipsis:  …(U+2026)

Pairs deliberately stay out of the set:

  "  '  )  ]  }

Stripping one side of a pair breaks balance. `who said "X"?` after
stripping `?` is balanced; further stripping `"` would yield
`who said "X` — different equivalence class than the original. And
apostrophes carry meaning: `X's` is a different question from `X`.

Lifted the strip set into a module constant `_QUESTION_TRAILING_STRIP`
so anyone considering an addition has a documented anchor.

Tests: 11 new in tests/test_keys.py — 6 CJK / ellipsis equivalences,
5 pair-preservation cases (double-quote, single-quote, paren, bracket,
brace). 24 keys tests + 297 default suite, all passing.

Whitepaper sibling change in ~/git/unfirehose-nextjs-logger/whitepaper/
merkle-providence-reverse-rag-whitepaper.rst — expanded the
question_hash bullet to document the equivalence class. Not committed
in this commit (different repo).
2026-04-29 09:06:35 -04:00

185 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cache-key dimension hashes — pure functions, deterministic.
The 8-dim cache_key invariants are spread across `aborist/qa/keys.py`.
Most behavior is covered indirectly by `test_qa.py` (cache hits, etc.);
this file pins the hash-input canonicalization rules so a future tweak
to question normalization can't silently change the equivalence class
of "what counts as the same question."
"""
from __future__ import annotations
from aborist.qa.keys import (
cache_key,
conversation_hash,
governance_policy_hash,
model_profile_hash,
question_hash,
)
# ---------------------------------------------------------------------------
# question_hash equivalence classes
# ---------------------------------------------------------------------------
def test_question_hash_strips_trailing_question_mark():
"""Fox's catch on 2026-04-29: `who is X?` and `who is X` should hit
the same providence_cache record. Trailing punctuation carries no
semantic difference for retrieval."""
assert question_hash("who is X") == question_hash("who is X?")
def test_question_hash_strips_trailing_period():
assert question_hash("explain X") == question_hash("explain X.")
def test_question_hash_strips_trailing_exclamation():
assert question_hash("explain X") == question_hash("explain X!")
def test_question_hash_strips_multiple_trailing_punct():
"""Multiple trailing chars all stripped — `?!` is a single class."""
assert question_hash("explain X") == question_hash("explain X?!")
assert question_hash("explain X") == question_hash("explain X.?")
def test_question_hash_lowercases():
assert question_hash("Who Is X") == question_hash("who is x")
def test_question_hash_collapses_internal_spaces():
"""canonicalize() collapses runs of spaces to a single space. Tabs/
newlines map to newline (a separate equivalence class — see
aborist.document.canonicalize), so tab vs space is intentionally
NOT in the same bucket."""
assert question_hash("who is X") == question_hash("who is X")
def test_question_hash_does_not_strip_internal_punctuation():
"""Internal `?` is a different question — ``what's up?`` mid-sentence
can carry meaning. Only trailing punctuation gets stripped."""
assert question_hash("X, then Y") != question_hash("X then Y")
# CJK + ellipsis equivalence — non-ASCII trailing punctuation should
# behave identically to ASCII forms. Pin so a future "let's go ASCII-only"
# refactor can't silently regress i18n.
def test_question_hash_strips_full_width_question_mark():
"""U+FF1F (CJK full-width question mark)."""
assert question_hash("X") == question_hash("X")
def test_question_hash_strips_full_width_exclamation():
"""U+FF01."""
assert question_hash("X") == question_hash("X")
def test_question_hash_strips_ideographic_full_stop():
"""U+3002 (Chinese / Japanese sentence-ending period)."""
assert question_hash("X") == question_hash("X。")
def test_question_hash_strips_ideographic_comma():
"""U+3001."""
assert question_hash("X") == question_hash("X、")
def test_question_hash_strips_ellipsis():
"""U+2026 — single-codepoint ellipsis, plus the three-dot ASCII form
that already worked via repeated `.` strip."""
assert question_hash("X") == question_hash("X…")
assert question_hash("X") == question_hash("X...")
def test_question_hash_strips_mixed_ascii_and_cjk_trailing():
"""A question ending with both kinds of trailing punctuation strips
cleanly. rstrip walks char-by-char from the right."""
assert question_hash("X") == question_hash("X.")
# Quotes and brackets PAIR — naive trailing-strip would break balance.
# Pin that they stay so a future "expand the strip set" refactor knows
# the design rule.
def test_question_hash_preserves_trailing_double_quote():
"""`who said "X"?` (after stripping `?`) ends in `"`. Stripping the
quote would unbalance the surface; the equivalence class would
no longer match a balanced version."""
assert question_hash('who said "X"') != question_hash('who said "X')
def test_question_hash_preserves_trailing_single_quote():
"""Apostrophes look like quotes. `X's` (possessive) must stay distinct
from `X` (the bare entity)."""
assert question_hash("who is X's") != question_hash("who is X")
def test_question_hash_preserves_trailing_close_paren():
"""`(foo)` — a paren pair carries grouping meaning."""
assert question_hash("who is X (Y)") != question_hash("who is X (Y")
def test_question_hash_preserves_trailing_close_bracket():
assert question_hash("who is X [a]") != question_hash("who is X [a")
def test_question_hash_preserves_trailing_close_brace():
assert question_hash("who is X {a}") != question_hash("who is X {a")
def test_question_hash_distinct_for_different_text():
"""Sanity: two genuinely different questions hash to different values."""
assert question_hash("who is X") != question_hash("who is Y")
def test_question_hash_is_deterministic():
"""Same input → same output, every time. No randomness."""
a = question_hash("who is russell ballestrini?")
b = question_hash("who is russell ballestrini?")
assert a == b
assert len(a) == 64 # sha256 hex
# ---------------------------------------------------------------------------
# Other dim hashes — light coverage so future refactors don't drift
# ---------------------------------------------------------------------------
def test_model_profile_hash_includes_revision_and_quantization():
a = model_profile_hash("m", revision="r1", quantization="q1")
b = model_profile_hash("m", revision="r2", quantization="q1")
c = model_profile_hash("m", revision="r1", quantization="q2")
assert a != b
assert a != c
assert b != c
def test_conversation_hash_canonicalizes_message_order():
"""Message ordering matters — a different order is a different
conversation. Pin so anyone tempted to sort messages knows it would
invalidate cache."""
msgs1 = [{"role": "system", "content": "a"}, {"role": "user", "content": "b"}]
msgs2 = [{"role": "user", "content": "b"}, {"role": "system", "content": "a"}]
assert conversation_hash(msgs1) != conversation_hash(msgs2)
def test_governance_policy_hash_sensitive_to_keys():
a = governance_policy_hash({"k": 1})
b = governance_policy_hash({"k": 2})
c = governance_policy_hash({"j": 1})
assert a != b
assert a != c
def test_cache_key_combines_all_eight_dims():
"""If any of the 8 dims changes, cache_key changes. v9.8 invariant."""
base = ("src", "qh", "mh", "ch", "gh", "v9.8.0", "norm-v1", "tok-512-v1")
base_key = cache_key(*base)
for i in range(8):
mutated = list(base)
mutated[i] = mutated[i] + "_modified"
assert cache_key(*mutated) != base_key, (
f"dim {i} change did not bump cache_key — 8-dim invariant broken"
)