arborist/tests/test_keys.py
russell@unturf.com 8d6961fcc1
aborist/arborist
modified:   .gitlab-ci.yml
	modified:   bench/qa_questions.txt
	modified:   bench/qa_sweep.py
	modified:   bench/run.sh
	modified:   docs/TICKETS.md
	modified:   docs/_source/README.md
	modified:   docs/_source/_ext/makefile_targets.py
	modified:   docs/_source/api/cli.rst
	modified:   docs/_source/api/distill.rst
	modified:   docs/_source/api/mesh.rst
	modified:   docs/_source/api/qa.rst
	modified:   docs/_source/api/retrieval.rst
	modified:   docs/_source/api/storage.rst
	modified:   docs/_source/api/substrate.rst
	modified:   docs/_source/concepts.rst
	modified:   docs/_source/conf.py
	modified:   docs/_source/cookbook.rst
	modified:   docs/_source/index.rst
	modified:   docs/_source/license.rst
	modified:   docs/_source/quickstart.rst
	modified:   docs/bench-maxing.md
	modified:   docs/benchmarks.md
	modified:   docs/cti-architecture.md
	modified:   docs/diagrams/aborist-modules.dot
	modified:   docs/diagrams/aborist-modules.svg
	modified:   docs/diagrams/mesh-data-flow.dot
	modified:   docs/diagrams/mesh-epoch-lifecycle.dot
	modified:   docs/diagrams/mesh-epoch-lifecycle.svg
	modified:   docs/diagrams/mesh-group-decisions.dot
	modified:   docs/diagrams/mesh-group-decisions.svg
	modified:   docs/diagrams/mesh-identity-stack.dot
	modified:   docs/diagrams/mesh-secret-envelope.dot
	modified:   docs/mesh.md
	modified:   docs/qa-modes-bench.md
	modified:   docs/seven-point-program.md
	modified:   docs/tickets/ticket-000001-retrieval-keywords-audit-gap.md
	modified:   docs/tickets/ticket-000002-reference-frame-polarity-contract.md
	modified:   docs/tickets/ticket-000003-anchor-class-warrant.md
	modified:   docs/tickets/ticket-000005-label-ladder-migration.md
	modified:   docs/tickets/ticket-000006-bench-emergent-findings.md
	modified:   docs/tickets/ticket-000007-query-layer-hyphen-fold.md
	modified:   docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md
	modified:   docs/tickets/ticket-000009-quantifier-preflight-dag-binding.md
	modified:   docs/tickets/ticket-000010-metacognition-preflight-guard.md
	modified:   docs/tickets/ticket-000011-soft-preflight-hint-sidecar.md
	modified:   scripts/backfill_concepts.py
	modified:   scripts/bench_emergent.py
	modified:   tests/crawler/test_async_web_fetcher.py
	modified:   tests/crawler/test_bridge.py
	modified:   tests/crawler/test_web_fetch.py
	modified:   tests/test_bench_qa_sweep.py
	modified:   tests/test_burn.py
	modified:   tests/test_burn_doc.py
	modified:   tests/test_claim_lattice.py
	modified:   tests/test_cli_render.py
	modified:   tests/test_compress.py
	modified:   tests/test_concepts.py
	modified:   tests/test_dag.py
	modified:   tests/test_directives.py
	modified:   tests/test_distill.py
	modified:   tests/test_distill_recursive.py
	modified:   tests/test_evict.py
	modified:   tests/test_frame.py
	modified:   tests/test_grok_source.py
	modified:   tests/test_html_source.py
	modified:   tests/test_ingest.py
	modified:   tests/test_inspect.py
	modified:   tests/test_journal.py
	modified:   tests/test_keys.py
	modified:   tests/test_llm_context_base.py
	modified:   tests/test_merkle.py
	modified:   tests/test_mesh.py
	modified:   tests/test_mesh_aead.py
	modified:   tests/test_mesh_chain.py
	modified:   tests/test_mesh_cli.py
	modified:   tests/test_mesh_cli_pull.py
	modified:   tests/test_mesh_wire.py
	modified:   tests/test_mesh_wire_e2e.py
	modified:   tests/test_metacognition.py
	modified:   tests/test_migration_audit_mode.py
	modified:   tests/test_providence_source.py
	modified:   tests/test_qa.py
	modified:   tests/test_qa_quality_live.py
	modified:   tests/test_quantifier_caps.py
	modified:   tests/test_quantifier_classifier.py
	modified:   tests/test_quantifier_phase4.py
	modified:   tests/test_quantifier_reminder.py
	modified:   tests/test_query.py
	modified:   tests/test_reclassify.py
	modified:   tests/test_repair.py
	modified:   tests/test_resume.py
	modified:   tests/test_snapshot.py
	modified:   tests/test_soft_preflight.py
	modified:   tests/test_tfidf.py
	modified:   tests/test_vcs_source.py
	modified:   tests/test_verify.py
	modified:   tests/test_verify_json.py
	modified:   tests/test_versioned_ingest.py
	modified:   tests/test_warrant.py
	modified:   tests/test_wikipedia_old.py
	modified:   tests/test_wikipedia_xml.py
	modified:   tests/test_wikitext.py
2026-05-07 09:31:49 -04:00

328 lines
12 KiB
Python
Raw Permalink 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 `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)