mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
f747d96c38
* feat(recall): fuzzy tag matching on tag_groups leaves (#4026) Tags increasingly hold user-facing names, and tag filtering is exact array containment. A caller filtering by what a query mentioned passes `typsecript`, and the memory tagged `typescript` is dropped before ranking runs — so the recall returns empty even though ranking would have found it. Better ranking cannot fix that; the match itself has to tolerate the misspelling. A `tag_groups` leaf gains one optional field, `resolve`, defaulting to `exact` (today's behaviour). Set to `fuzzy`, its tags are matched against the bank's tags by similarity instead of literally. That is the whole API change: no new config, no new response field, no new TagsMatch values. Matching is trigram similarity at 0.45 via `entity_resolver._trigram_similarity`, already verified byte-identical to Postgres `similarity()` (#3107), so Postgres, Oracle and store-owned backends behave the same. Resolves: typescropt/typescript 0.57, kubernets/kubernetes 0.62, user:alcie/user:alice 0.47. Does not: mango/mongo 0.33, k9s/k8s 0.14. Known limit, pinned by a test: similarity is length-sensitive. A short tag has few trigrams and one edit destroys three of them, so kakfa/kafka scores 0.20 and does not resolve. Fuzzy matching is effective on descriptive tags and close to inert on very short ones — and that same property is what keeps different short words apart. Resolution runs above the SQL layer, rewriting the leaf into ordinary exact leaves so only those reach the query builders. The ~20 SQL call sites, the Python mirrors used on the graph path, the GIN(tags) index, the store protocol and the Oracle dialect are untouched. Per mode, for tokens t1..tn resolving to E1..En: any/any_strict becomes one leaf over the union; all/all_strict becomes an AND of one OR-leaf per token, so a memory must carry some spelling of each; exact becomes an OR over the cross product, one tag per token, bounded at 32 branches and checked before enumeration, with combinations carrying fewer distinct tags than tokens dropped. Failing closed: a tag that resolves to nothing stays in the filter as itself, leaving the leaf unsatisfiable. Returning an empty list would read as "no tag filtering" in the builders and hand back the whole bank. The vocabulary comes from the existing `list_tags` store method, so there is no schema change. A bank holding more than 5000 distinct tags is rejected with a 422 rather than resolved against a truncated vocabulary. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * fix(clients): make tag_groups reachable through the wrapper SDKs `Hindsight.recall(tag_groups=...)` and `.reflect(tag_groups=...)` raised ModuleNotFoundError for every caller. The wrapper imported `hindsight_client_api.models.recall_request_tag_groups_inner`, which the generator does not emit: it produces one union model per tag_groups shape and names it after the first schema that used it, so the class is `MentalModelTriggerInputTagGroupsInner`. Nothing caught it because the wrapper's tests never passed tag_groups and the import sits inside the `if tag_groups is not None` branch, so it only fires when the feature is used. Fixed at both call sites, with mirrored regression tests on the Python and TypeScript wrappers asserting a tag group reaches the request body with the leaf's `resolve` intact — the pair the review checklist asks for, since a capability that exists in one wrapper and not the other is invisible to client-coverage-check (it validates request-body fields, not wrapper surface). Also thread tag_groups through the control-plane recall and reflect proxy routes and their client types. Both accepted every other tag filter and silently dropped this one, so no control-plane caller could use compound tag filtering at all — fuzzy or exact. Two follow-ups from reviewing #4026: - Reject `resolve="fuzzy"` in a mental-model trigger's tag_groups. A trigger's scope is read by two paths that resolve differently: the refresh runs through reflect, which resolves fuzzy leaves, while the staleness check and the scope watermark build SQL straight from the stored groups and do not. A stored fuzzy leaf would build content from the resolved tags while never being marked stale by them, and would drift as the bank's tag vocabulary changes. - Promote `entity_resolver._trigram_similarity` to `trigram_similarity`. Two subsystems now share it — entity resolution and fuzzy tag matching — so the leading underscore misrepresented a real contract between them. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk
87 lines
3.5 KiB
Python
87 lines
3.5 KiB
Python
"""The perf fixture's entity-graph shape (issue #3510).
|
|
|
|
``recall-with-observations`` only exercises the observation graph arm's real
|
|
failure mode if its synthetic bank is shaped like a real one. The property that
|
|
does the work is the *post-resolution* entity graph: a few hubs over a long tail
|
|
of rarely-mentioned entities, so a handful of observation seeds reaches a small
|
|
fraction of the graph rather than all of it.
|
|
|
|
These tests assert that outcome rather than the mechanism. The mechanism has
|
|
already failed in a non-obvious way once — tail names generated as
|
|
"<stem> <counter>" scored 0.73 pg_trgm against their neighbours, so the entity
|
|
resolver merged 2,814 generated names back down to 159 and the bank came out with
|
|
the flat hub-only graph the vocabulary exists to avoid. Checking name distances
|
|
directly would flag harmless pairs and still miss that outcome.
|
|
"""
|
|
|
|
import statistics
|
|
from collections import Counter
|
|
|
|
from benchmarks.perf.recall_perf import (
|
|
ENTITIES,
|
|
FACT_TEMPLATES,
|
|
_build_vocabulary,
|
|
_fill_template,
|
|
configure_entity_vocabulary,
|
|
)
|
|
|
|
# The entity resolver fuzzy-merges two names within a retain batch at or above
|
|
# this pg_trgm similarity (EntityResolver.intrabatch_merge_similarity).
|
|
_MERGE_SIMILARITY = 0.5
|
|
_CORPUS = 5_000
|
|
|
|
|
|
def _mentions_after_resolution(corpus_size: int) -> Counter:
|
|
"""Entity mention counts as the bank would hold them, post fuzzy-merge.
|
|
|
|
Simulates the resolver's intra-batch merge so the shape assertions below
|
|
describe the graph that actually lands in ``unit_entities``, not the names
|
|
the generator emitted.
|
|
"""
|
|
from hindsight_api.engine.entity_resolver import trigram_similarity
|
|
|
|
configure_entity_vocabulary(corpus_size)
|
|
raw: Counter = Counter()
|
|
for i in range(corpus_size):
|
|
for entity in _fill_template(FACT_TEMPLATES[i % len(FACT_TEMPLATES)]).entities[:3]:
|
|
raw[entity] += 1
|
|
|
|
canonical: dict[str, str] = {}
|
|
for name in raw:
|
|
for seen in canonical:
|
|
if trigram_similarity(name, seen) >= _MERGE_SIMILARITY:
|
|
canonical[name] = canonical[seen]
|
|
break
|
|
else:
|
|
canonical[name] = name
|
|
|
|
merged: Counter = Counter()
|
|
for name, count in raw.items():
|
|
merged[canonical[name]] += count
|
|
return merged
|
|
|
|
|
|
def test_vocabulary_grows_with_the_corpus():
|
|
"""A fixed-size vocabulary makes degree scale with bank size, not entity count."""
|
|
assert len(_build_vocabulary(len(ENTITIES))) == len(ENTITIES), "the hub head is the floor"
|
|
assert len(_build_vocabulary(500)) < len(_build_vocabulary(5_000)), "vocabulary must grow with the corpus"
|
|
|
|
|
|
def test_entity_graph_keeps_a_long_tail():
|
|
"""Hubs over a tail of rare entities — the shape the #3510 bank had.
|
|
|
|
That bank ran median degree 2 with a third of its entities mentioned once and
|
|
its top entity on 8.7% of links. The old fixed list produced median degree 40
|
|
and *zero* entities mentioned once, so every seed reached the whole graph.
|
|
"""
|
|
merged = _mentions_after_resolution(_CORPUS)
|
|
total = sum(merged.values())
|
|
|
|
assert len(merged) > 800, (
|
|
f"only {len(merged)} entities survived resolution for {_CORPUS} facts — the tail is "
|
|
"collapsing, most likely because generated names fuzzy-merge into each other"
|
|
)
|
|
assert statistics.median(merged.values()) <= 8, "the median entity must stay rare"
|
|
assert sum(1 for c in merged.values() if c == 1) > 50, "a real bank has entities seen once"
|
|
assert max(merged.values()) / total > 0.02, "and it still has hubs"
|