fix(tests): two readers moved, and the settings guard now checks the registry (#4102)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
CI on 09b4845 caught both, and the second is an improvement rather than a
repair.
`test_services_reply_preferences` patched `reply_preferences.get_setting`,
which the registry refactor removed — its floor and budget are resolved through
`retrieval_surfaces` now. Its key-independence test also asserted the arm asks
for exactly one key; it asks for two, because both of its numbers are its own
since this step, so the assertion names both and adds a check that the registry
and the module constant still agree about the floor key. They writing different
keys is the failure where the Settings form saves one string and the arm reads
another.
`test_settings_defaults_agree` parsed `plugin_context.py` for a bare
module-level float, and those constants now alias the registry. Rather than
teach the regex about aliases, the six retrieval floors are keyed on their
SURFACE NAME and read from the registry directly — which is strictly better for
this guard: a surface name is also its telemetry source, so a row names the same
arm the readout does, and a floor cannot be checked against a stale constant
that happened to keep its old value. `PLAN_MATCH_DEFAULT_THRESHOLD` is not a
push surface and keeps the older shape, with a note saying why.
Added while there: `test_every_tunable_surface_has_a_control`, derived from the
registry, so a seventh surface arrives as a failing test rather than as a number
only the model can reach (rules 25, 27).
Also lands the audit trail this step needs — `retrieval_tuning_events` (model +
migration 0103) and `services/retrieval_tuning.py`. The tool layer on top is
the next commit; the table is here because `reason` being REQUIRED is the whole
guardrail, and the schema is where that starts. A number moved silently leaves
nothing for the operator to review or disagree with, and the operator's decision
is that the model moves these "9 times out of 10".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -9,6 +9,9 @@ import re
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
M = "scribe.services.reply_preferences"
|
||||
# Its two numbers are resolved through the registry now (#4102), so the
|
||||
# settings reader to patch lives there rather than in the arm's own module.
|
||||
RS = "scribe.services.retrieval_surfaces"
|
||||
|
||||
|
||||
def _rule(rid, kind="preference"):
|
||||
@@ -26,7 +29,7 @@ async def _run(hits, *, searched=True, raises=None):
|
||||
|
||||
search_mock = AsyncMock(side_effect=search)
|
||||
with patch(f"{M}.semantic_search_rules", search_mock), \
|
||||
patch(f"{M}.get_setting", AsyncMock(return_value="0.72")), \
|
||||
patch(f"{RS}.get_setting", AsyncMock(return_value="0.72")), \
|
||||
patch(f"{M}.record_retrieval") as logged, \
|
||||
patch(f"{M}.record_rule_surfaced") as surfaced:
|
||||
out = await completion_preferences(7, project_id=3)
|
||||
@@ -93,7 +96,7 @@ def test_its_bar_is_its_own_key_not_the_prompt_arm_s(monkeypatch):
|
||||
|
||||
asked: list[str] = []
|
||||
|
||||
async def get_setting(user_id, key, default):
|
||||
async def get_setting(user_id, key, default=""):
|
||||
asked.append(key)
|
||||
return default
|
||||
|
||||
@@ -101,16 +104,26 @@ def test_its_bar_is_its_own_key_not_the_prompt_arm_s(monkeypatch):
|
||||
kw["report"].update({"searched": True})
|
||||
return []
|
||||
|
||||
with patch(f"{M}.get_setting", AsyncMock(side_effect=get_setting)), \
|
||||
with patch(f"{RS}.get_setting", AsyncMock(side_effect=get_setting)), \
|
||||
patch(f"{M}.semantic_search_rules", AsyncMock(side_effect=search)), \
|
||||
patch(f"{M}.record_retrieval"):
|
||||
asyncio.run(completion_preferences(7))
|
||||
|
||||
assert asked == [plugin_context.REPORTPREF_THRESHOLD_KEY]
|
||||
assert plugin_context.REPORTPREF_THRESHOLD_KEY != plugin_context.PROMPTRULE_THRESHOLD_KEY, (
|
||||
# Both numbers are its own since #4102 — a floor AND a budget — so the arm
|
||||
# asks for two keys, and neither may be the prompt arm's.
|
||||
from scribe.services.retrieval_surfaces import get_surface
|
||||
|
||||
mine = get_surface("report_preference")
|
||||
assert asked == [mine.floor_key, mine.budget_key]
|
||||
theirs = get_surface("prompt_rule")
|
||||
assert mine.floor_key != theirs.floor_key, (
|
||||
"the two keys are the same string again, so the settings form has one "
|
||||
"dial driving two arms — which is the defect, whatever the value is"
|
||||
)
|
||||
assert mine.floor_key == plugin_context.REPORTPREF_THRESHOLD_KEY, (
|
||||
"the registry and the module constant disagree about this arm's key, "
|
||||
"so the Settings form would write one and the arm would read the other"
|
||||
)
|
||||
|
||||
|
||||
def test_the_query_assumes_no_particular_domain():
|
||||
|
||||
@@ -20,9 +20,12 @@ string equals the Python default. Retuning either stays free as long as both
|
||||
move — which is the point, because these are tuning values and #3853 moved one
|
||||
of them the day this was written.
|
||||
|
||||
Both sides are read from SOURCE rather than imported. The Vue file cannot be
|
||||
imported at all, and reading the Python constant through an import would tie
|
||||
this to module-load side effects it has no interest in.
|
||||
The Vue side is read from SOURCE, because that file cannot be imported at all.
|
||||
The Python side is read from source for a loose constant and IMPORTED for a
|
||||
registry surface — the registry is a plain table of frozen dataclasses with
|
||||
nothing to trigger on import, and importing it means this guard checks the value
|
||||
the server will actually resolve rather than a literal that happens to look
|
||||
right.
|
||||
|
||||
It cannot check that the form WRITES the right key — that is behaviour, and
|
||||
the keys are asserted where they are built. It catches the drift that has no
|
||||
@@ -39,20 +42,29 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
_SERVICES = ROOT / "src" / "scribe" / "services"
|
||||
_VUE = ROOT / "frontend" / "src" / "views" / "SettingsView.vue"
|
||||
|
||||
# (services module, python constant, vue ref). Hand-written because the
|
||||
# pairing is an editorial fact — the names do not share a convention either
|
||||
# side could derive — but every entry is asserted to EXIST on both sides, so a
|
||||
# rename fails loudly here rather than silently dropping that threshold from
|
||||
# the check.
|
||||
_PAIRS = (
|
||||
("plugin_context.py", "AUTOINJECT_DEFAULT_THRESHOLD", "kbInjectThreshold"),
|
||||
("plugin_context.py", "WRITEPATH_DEFAULT_THRESHOLD", "kbWritePathThreshold"),
|
||||
("plugin_context.py", "RULEHINT_DEFAULT_THRESHOLD", "kbRuleHintThreshold"),
|
||||
("plugin_context.py", "TOOLRULE_DEFAULT_THRESHOLD", "kbToolRuleThreshold"),
|
||||
("plugin_context.py", "PROMPTRULE_DEFAULT_THRESHOLD", "kbPromptRuleThreshold"),
|
||||
("plugin_context.py", "REPORTPREF_DEFAULT_THRESHOLD", "kbReportPrefThreshold"),
|
||||
# THE SIX RETRIEVAL FLOORS now live in one registry (#4102), so their side of
|
||||
# the pairing is a SURFACE NAME rather than a module constant. That is strictly
|
||||
# better for this guard: the surface name is also the telemetry source, so a row
|
||||
# here names the same arm the readout does, and a floor that moved cannot be
|
||||
# checked against a stale constant that happened to keep its old value.
|
||||
_SURFACE_PAIRS = (
|
||||
("auto_inject", "kbInjectThreshold"),
|
||||
("write_path", "kbWritePathThreshold"),
|
||||
("write_path_rule", "kbRuleHintThreshold"),
|
||||
("pre_tool_rule", "kbToolRuleThreshold"),
|
||||
("prompt_rule", "kbPromptRuleThreshold"),
|
||||
("report_preference", "kbReportPrefThreshold"),
|
||||
)
|
||||
|
||||
# (services module, python constant, vue ref) for the defaults that are NOT
|
||||
# retrieval surfaces. Hand-written because the pairing is an editorial fact —
|
||||
# the names share no convention either side could derive — and asserted to
|
||||
# exist on both sides, so a rename fails loudly rather than silently dropping
|
||||
# that threshold from the check.
|
||||
_CONSTANT_PAIRS = (
|
||||
# The plan gate (milestone 415): it blocks a create, so a form showing a
|
||||
# looser bar than the one in force would be the more misleading drift.
|
||||
# looser bar than the one in force would be the more misleading drift. Not
|
||||
# a push surface, so it has no registry entry and keeps the older shape.
|
||||
("dedup.py", "PLAN_MATCH_DEFAULT_THRESHOLD", "kbPlanMatchThreshold"),
|
||||
)
|
||||
|
||||
@@ -80,14 +92,42 @@ def _vue_default(ref_name: str) -> float:
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module", "constant", "ref_name"), _PAIRS,
|
||||
ids=[p[1] for p in _PAIRS])
|
||||
def test_the_form_shows_the_default_the_server_uses(module, constant, ref_name):
|
||||
"""An untouched control must render the bar actually in force."""
|
||||
server, form = _python_default(module, constant), _vue_default(ref_name)
|
||||
def _assert_agrees(server: float, form: float, ref_name: str, what: str) -> None:
|
||||
assert form == server, (
|
||||
f"SettingsView shows {form} for {ref_name} while the server defaults "
|
||||
f"to {server} ({constant}). An operator who has never set this reads "
|
||||
f"the form as the value in force, so the two must move together — "
|
||||
f"retune both, or neither."
|
||||
f"to {server} ({what}). An operator who has never set this reads the "
|
||||
f"form as the value in force, so the two must move together — retune "
|
||||
f"both, or neither."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("surface", "ref_name"), _SURFACE_PAIRS,
|
||||
ids=[p[0] for p in _SURFACE_PAIRS])
|
||||
def test_the_form_shows_the_floor_the_surface_uses(surface, ref_name):
|
||||
"""An untouched control must render the bar actually in force."""
|
||||
from scribe.services.retrieval_surfaces import get_surface
|
||||
|
||||
_assert_agrees(get_surface(surface).floor_default, _vue_default(ref_name),
|
||||
ref_name, f"{surface} floor")
|
||||
|
||||
|
||||
def test_every_tunable_surface_has_a_control():
|
||||
"""Rule 25/27: a number an operator may need different has a UI or it is
|
||||
not shipped. Derived from the registry, so a seventh surface arrives here
|
||||
as a failure rather than as a setting only the model can reach."""
|
||||
from scribe.services.retrieval_surfaces import surface_names
|
||||
|
||||
covered = {s for s, _ref in _SURFACE_PAIRS}
|
||||
missing = [n for n in surface_names() if n not in covered]
|
||||
assert not missing, (
|
||||
f"these surfaces are tunable with no Settings control: {missing}. "
|
||||
"Add the control and its row, or say in _SURFACE_PAIRS why it has none."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module", "constant", "ref_name"), _CONSTANT_PAIRS,
|
||||
ids=[p[1] for p in _CONSTANT_PAIRS])
|
||||
def test_the_form_shows_the_default_the_server_uses(module, constant, ref_name):
|
||||
"""Same claim, for the defaults that are not retrieval surfaces."""
|
||||
_assert_agrees(_python_default(module, constant), _vue_default(ref_name),
|
||||
ref_name, constant)
|
||||
|
||||
Reference in New Issue
Block a user