"""Cache-key dimension hashes — pure functions, deterministic. The 8-dim cache_key invariants are spread across `arborist/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 arborist.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 arborist.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") # Article stripping — fox's 2026-04-29 catch: "the batman" and "batman" # produced different cache records. Articles are filler at the question- # equivalence layer; strip them so cache lookups dedupe. def test_question_hash_strips_leading_article_the(): """`who is the batman` and `who is batman` ask the same question.""" assert question_hash("who is the batman") == question_hash("who is batman") def test_question_hash_strips_leading_article_a(): assert question_hash("what is a hot dog") == question_hash("what is hot dog") def test_question_hash_strips_leading_article_an(): assert question_hash("what is an apple") == question_hash("what is apple") def test_question_hash_strips_articles_anywhere_in_question(): """Articles in the middle of the sentence also strip.""" assert ( question_hash("does the cat sit on a mat") == question_hash("does cat sit on mat") ) def test_question_hash_does_not_strip_article_substrings(): """`thesis` contains `the` but is one token; substring matching would corrupt it. Tokens are matched as exact lowercase strings.""" assert ( question_hash("explain my thesis") != question_hash("explain my sis") ) # And `thesis` survives the strip: assert "explain my thesis" in question_hash.__doc__ or True # docstring sanity # The actual canonical form has `thesis`: h_thesis = question_hash("thesis") h_other = question_hash("the sis") # "the sis" → "sis" after article strip; "thesis" stays "thesis" assert h_thesis != h_other def test_question_hash_handles_question_with_only_articles_and_topic(): """Edge: very short question. `the X` and `X` and `a X` collapse.""" assert question_hash("the batman") == question_hash("batman") assert question_hash("a batman") == question_hash("batman") assert question_hash("an batman") == question_hash("batman") def test_question_hash_distinct_topics_still_distinct(): """Article strip doesn't accidentally collapse different topics.""" assert question_hash("the batman") != question_hash("the superman") assert question_hash("a batman") != question_hash("a superman") 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" ) # ---------------------------------------------------------------- verifier_policy_hash def test_verifier_policy_hash_only_hashes_verifier_subset(): """Non-verifier fields (temperature, prompts, etc.) must NOT enter verifier_policy_hash. The whole point of separating it from governance_policy_hash is to surface verifier-rule changes independently from prompt / sampling-knob changes. """ from arborist.qa.keys import verifier_policy_hash base = { "answer_mode": "claim_lattice_pointer", "claim_lattice_min_citation_coverage": 0.30, "temperature": 0.1, # non-verifier "system_prompt": "blah", # non-verifier "max_tokens": 512, # non-verifier "grounding_reminder": "blah", # non-verifier } h_base = verifier_policy_hash(base) # Changing a non-verifier field must NOT change the hash. h_temp = verifier_policy_hash(dict(base, temperature=0.5)) h_prompt = verifier_policy_hash(dict(base, system_prompt="something else")) h_tokens = verifier_policy_hash(dict(base, max_tokens=2048)) assert h_base == h_temp assert h_base == h_prompt assert h_base == h_tokens def test_verifier_policy_hash_changes_when_verifier_field_changes(): """Verifier-relevant fields MUST change the hash. Bumping any one of these means the verifier rules differ and a new cache record is required. """ from arborist.qa.keys import verifier_policy_hash base = { "answer_mode": "claim_lattice_pointer", "claim_lattice_min_citation_coverage": 0.30, "claim_lattice_max_pointers_per_claim": 2, "claim_lattice_min_claim_content_tokens": 2, } h_base = verifier_policy_hash(base) assert verifier_policy_hash(dict(base, answer_mode="quote")) != h_base assert verifier_policy_hash( dict(base, claim_lattice_min_citation_coverage=0.50) ) != h_base assert verifier_policy_hash( dict(base, claim_lattice_max_pointers_per_claim=3) ) != h_base assert verifier_policy_hash( dict(base, claim_lattice_min_claim_content_tokens=3) ) != h_base def test_verifier_policy_hash_empty_policy_is_stable(): """Empty policy → constant hash. Stable across runs.""" from arborist.qa.keys import verifier_policy_hash assert verifier_policy_hash({}) == verifier_policy_hash({}) def test_cache_key_nine_dim_form_distinguishes_verifier_policy(): """Adding the 9th dimension produces a distinct cache_key from the 8-dim form. Records written under 8-dim form cannot be retrieved under 9-dim form even when all common dims match — that's the migration cost of adding the dimension.""" eight = ("src", "qh", "mh", "ch", "gh", "v9.8.0", "norm-v1", "tok-512-v1") k_eight = cache_key(*eight) k_nine = cache_key(*eight, "vh") assert k_eight != k_nine def test_cache_key_nine_dim_changes_with_verifier_hash(): """Two records with identical 8 dims but different verifier_policy_hash get distinct cache_keys.""" eight = ("src", "qh", "mh", "ch", "gh", "v9.8.0", "norm-v1", "tok-512-v1") a = cache_key(*eight, "vh-1") b = cache_key(*eight, "vh-2") assert a != b def test_cache_key_eight_dim_form_preserved_for_legacy_callers(): """Calling cache_key with 8 args (or with the 9th = None) must return the same hash a pre-2026-05-01 caller would have got. Backward-compat is the legacy-INSERT path's lifeline.""" eight = ("src", "qh", "mh", "ch", "gh", "v9.8.0", "norm-v1", "tok-512-v1") assert cache_key(*eight) == cache_key(*eight, None)