Files
FabledScribe/tests/test_services_rule_embeddings.py
T
bvandeusenandClaude Opus 5 95a37318fc
CI & Build / Python lint (push) Failing after 9s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Skipped
feat(rules): rules become findable by meaning (#3030, milestone 307 step 4)
Rules were the only major record type with no vector, so `search` could never
return one and a rule could arrive only by being preloaded. That single fact is
what made every rule compete for one always-on budget.

THE DECISION THE TASK ASKED FOR, made explicitly: a sibling rule_embeddings
table, not a polymorphic embedding row. The ROW could have been generalised;
the SEARCH could not. semantic_search_notes is Note-specific scoping end to end
— the visibility clause, the supersession penalty, note_type/task_kind/system
filters — and a rule shares none of it, scoping instead by rulebook ownership
or project. Generalising the row while still needing two searches is the worst
of both: a key with referential integrity to neither table, on the path every
session start runs, to share four columns. What is genuinely common is
BEHAVIOUR — get_embedding, chunk_document, embedding_text, CHUNKER_VERSION —
and those are reused as-is. Sharing them is the DRY win; sharing the table
would have been the DRY costume.

The document shape is measured, not chosen (note 2485). That pass found the
snippet was the only discriminative record in the corpus — a 0.153
top-to-second gap against 0.010-0.023 — and that the cause was its SHAPE:
purpose stated twice in a short single-topic document. rule_document
reproduces it: the trigger in the title AND as the body's first line.

And it excludes `why`, which matters more than any of it. `why` is dated
incident narrative — rule 46's runs to 4,300 characters — and long multi-topic
prose is exactly what made sixteen dev-logs mutually indistinguishable. Adding
it would not give the vector more to work with; it would give every rule the
SAME thing to work with. rule_document takes no `why` parameter at all, so a
well-meaning caller cannot pass one.

A rule with no trigger degrades to title + statement — findable, less sharp.
That is an argument for backfilling triggers (step 6), not for padding the
document with whatever text is nearby.

search(content_type="rule") returns the rule WITH its why and how_to_apply:
they are its operational half, the session payload never carries them, and a
caller who went looking should not have to re-fetch. Writes re-index
fire-and-forget like notes; startup backfills in its own try block so neither
backfill can skip the other. rule_embeddings is derived, so it joins
note_embeddings in the backup's explicitly-NOT-included list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:00:49 -04:00

85 lines
3.9 KiB
Python

"""The document a rule is EMBEDDED as (milestone 307 step 4, note 3026).
This shape is measured, not chosen. Note 2485 probed the live corpus and found
the snippet was the only discriminative record in it — a 0.153 top-to-second
gap against 0.010-0.023 for everything else — and that the cause was its shape:
purpose stated twice in a short, single-topic document. These cases pin that
recipe onto rules, and pin the exclusion that matters more than any of it.
"""
import pytest
from scribe.services.embeddings import chunk_document, rule_document
def test_the_trigger_appears_twice_which_is_what_makes_a_vector_sharp():
"""Repetition of purpose + brevity is the measured cause of the snippet's
separation. The rule document reproduces it exactly: the trigger in the
title, and again as the body's first line."""
title, body = rule_document(
"Release — never without explicit request",
"Never cut a release without the operator explicitly asking.",
"before cutting any release",
)
assert title == "Release — never without explicit request — before cutting any release"
assert body.startswith("When to apply: before cutting any release")
assert "Never cut a release" in body
def test_why_is_never_embedded():
"""The exclusion this whole design turns on. `why` is dated incident
narrative — rule 46's runs to 4,300 characters — and long multi-topic prose
is what made sixteen dev-logs mutually indistinguishable: the average lands
on a centroid they all share. Adding it would not give the vector more to
work with; it would give every rule the SAME thing to work with.
rule_document takes no `why` parameter at all, which is the strongest form
of this guarantee: it cannot be passed in by a caller who means well.
"""
import inspect
assert "why" not in inspect.signature(rule_document).parameters
def test_a_rule_with_no_trigger_still_embeds_just_less_sharply():
"""Every rule written before milestone 307 has no trigger. Degrading to
title + statement keeps them findable; it does NOT pad the document with
whatever text is lying around, which would be the tempting fix and the
wrong one."""
title, body = rule_document("dev is home", "Work directly on dev.", "")
assert title == "dev is home"
assert body == "Work directly on dev."
assert "When to apply" not in body
def test_an_empty_rule_yields_no_document_and_therefore_no_vector():
"""Callers gate on falsiness to skip embedding — the same contract
chunk_document has, so an emptied record stops being findable by its old
content rather than keeping a stale vector."""
assert rule_document("", "", "") == (None, None)
assert chunk_document(*rule_document("", "", "")) == []
def test_a_long_rule_chunks_and_every_chunk_carries_the_trigger():
"""Rule 46's statement is ~3,600 characters across two headed sections.
The existing chunker splits it at headings and prefixes each chunk with the
title — which now CONTAINS the trigger, so each half stays anchored to what
the rule is for. This is why splitting a merged rule costs nothing at
retrieval time."""
statement = (
"## The tags\n\n" + ("Four tags, four jobs. " * 60)
+ "\n\n## The artifact's own version\n\n" + ("Two version values. " * 60)
)
title, body = rule_document("Versioning", statement, "when cutting a release")
chunks = chunk_document(title, body)
assert len(chunks) > 1
assert all("when cutting a release" in chunk for chunk in chunks)
@pytest.mark.parametrize("trigger,expected_title", [
("before any git push", "dev is home — before any git push"),
(" before any git push ", "dev is home — before any git push"),
])
def test_the_trigger_is_trimmed_before_it_reaches_the_vector(trigger, expected_title):
title, _ = rule_document("dev is home", "Work on dev.", trigger)
assert title == expected_title