Supersession (steps 1–4) — corrections demote, state lives on Systems (#101)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 18s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 18s
This commit was merged in pull request #101.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""note_supersessions; drop the never-written notes.consolidated_at
|
||||
|
||||
Revision ID: 0076
|
||||
Revises: 0075
|
||||
Create Date: 2026-08-07
|
||||
|
||||
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
||||
table yet, and nothing behaves differently after this runs.
|
||||
|
||||
## What the table is for
|
||||
|
||||
Old records outrank newer ones on the same subject, because a similarity score
|
||||
cannot tell time. A note that accurately described how something worked in June
|
||||
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
|
||||
that, so nothing could act on it.
|
||||
|
||||
The claim points FORWARD — the newer record names what it overtakes — because
|
||||
the older one cannot know it has been overtaken. Many-to-many and partial: a
|
||||
note may supersede parts of several others and be overtaken piecemeal by
|
||||
several later ones, which is why this is a table rather than a column. Both
|
||||
directions are queried: `superseded_id` answers "has this been overtaken?" at
|
||||
ranking time, `superseder_id` answers "what does this replace?" in a record
|
||||
view. An array column could serve one and not the other.
|
||||
|
||||
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
|
||||
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
|
||||
them back. The cascade fires only on `purge_trash`, where the row genuinely
|
||||
goes — and a claim about a row that no longer exists is not actionable.
|
||||
|
||||
## What is being dropped, and why now
|
||||
|
||||
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
|
||||
— while being serialised into every note and task payload as `null`. It cost a
|
||||
column, a line in every response, and worse: it IMPLIED a capability. A reader
|
||||
reasonably concludes notes can be consolidated and this records when.
|
||||
|
||||
That reading was reasonable precisely because merge/unmerge exists for snippets
|
||||
and not for notes, so the column looked like the notes-side half of that
|
||||
feature, modelled and abandoned.
|
||||
|
||||
It is dropped rather than repurposed for supersession, and the distinction is
|
||||
the point (#2483): consolidation folds several records into one survivor and
|
||||
destroys the originals. Merging two snippets is lossless — one helper, several
|
||||
call sites. Folding two dev-logs means writing a summary and losing what each
|
||||
actually said. Supersession is the opposite act: both records survive, and the
|
||||
older one is merely ranked behind. Smuggling one in under a column named for
|
||||
the other would have buried that difference in schema.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
|
||||
restores the shape, not the (nonexistent) data. Drops the table; any recorded
|
||||
supersession claims are lost, which costs ranking its input and nothing else,
|
||||
since no note's own content depends on them.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0076"
|
||||
down_revision = "0075"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_supersessions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"superseder_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"superseded_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||
),
|
||||
# Declaring that a note supersedes ITSELF is meaningless, and under flat
|
||||
# demotion it would demote a record on its own authority. Refused in the
|
||||
# service too, with a message — this is the backstop that holds when
|
||||
# something writes rows directly.
|
||||
sa.CheckConstraint(
|
||||
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
|
||||
)
|
||||
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
|
||||
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
|
||||
op.drop_table("note_supersessions")
|
||||
@@ -10,7 +10,6 @@ export interface Note {
|
||||
title: string;
|
||||
body: string;
|
||||
description: string | null;
|
||||
consolidated_at: string | null;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
parent_title?: string | null;
|
||||
|
||||
@@ -41,7 +41,6 @@ const toast = useToastStore();
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const description = ref("");
|
||||
const consolidatedAt = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
@@ -303,7 +302,6 @@ onMounted(async () => {
|
||||
title.value = store.currentTask.title;
|
||||
body.value = store.currentTask.body;
|
||||
description.value = store.currentTask.description ?? "";
|
||||
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
||||
tags.value = [...(store.currentTask.tags || [])];
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
@@ -1063,22 +1061,4 @@ useEditorGuards(dirty, save);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
||||
.auto-summary-banner-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.auto-summary-banner-editor .auto-summary-icon {
|
||||
color: var(--color-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
|
||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.currentTask.consolidated_at"
|
||||
class="auto-summary-banner"
|
||||
>
|
||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
||||
Auto-summarized from work logs.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="body prose"
|
||||
@@ -771,17 +764,4 @@ const subTaskProgress = computed(() => {
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.auto-summary-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auto-summary-icon {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.25",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -81,6 +81,25 @@ Two constraints on *how* that's achieved:
|
||||
(`arose_from_id`) and the subsystem it touches (`system_ids`). Don't bury a
|
||||
fix as a work-log line on whatever task happened to be open.
|
||||
|
||||
7. **Tag records to Systems.** `enter_project` lists the project's Systems —
|
||||
its named subsystems/areas. When you create or meaningfully update a record,
|
||||
ask which areas it is *about* and pass `system_ids`. The test: would someone
|
||||
investigating that subsystem want this record in the pile
|
||||
`list_system_records` returns? If the area has no System yet, create one
|
||||
(`create_system`: name + a one-paragraph charter) — an area that plainly
|
||||
exists deserves naming the moment two records would share it; don't wait to
|
||||
be asked. A record about no particular area takes none.
|
||||
|
||||
8. **State updates in place; chronicles don't.** A dev-log records what
|
||||
*happened* — write it once, never rewrite it. A durable finding (how a
|
||||
subsystem works, a measured number) lives in that System's **reference
|
||||
note** ("«System» — reference"), which you UPDATE as facts change — safe,
|
||||
because every meaningful edit is snapshotted and the version history is the
|
||||
changelog. The dev-log then `[[links]]` the reference note instead of
|
||||
restating state. When a new record outright *corrects* an older one (a
|
||||
re-measurement, a reversed decision), pass the old id in `supersedes` so the
|
||||
stale record is demoted and labelled rather than left competing.
|
||||
|
||||
## Stay inside the active project's scope
|
||||
|
||||
Once a project is in scope — you called `enter_project`, or the working repo is
|
||||
|
||||
@@ -54,10 +54,29 @@ What each part is for, and when to reach for it:
|
||||
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
||||
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
||||
code.
|
||||
- System: a per-project, reusable, self-describing subsystem/area. Associate any
|
||||
record (note, task, issue) with it via system_ids so research, build-work, and
|
||||
fixes for the same area line up, and recurring problem-spots surface. Manage
|
||||
with create_system / list_systems / get_system.
|
||||
- System: a per-project, reusable, self-describing subsystem/area — the
|
||||
project's vocabulary for WHERE work happens. enter_project returns the list.
|
||||
TAG AS YOU WRITE: when you create or meaningfully update a note, task, or
|
||||
snippet, ask which of those areas it is about and pass system_ids. The test:
|
||||
would someone investigating that subsystem want this record in the pile
|
||||
list_system_records returns? Cross-cutting records take several; a record
|
||||
about no particular area takes none — don't force it. If the area a record
|
||||
describes has no System yet, CREATE it (create_system: name + a one-paragraph
|
||||
charter) and tag the record — a subsystem that exists in the code deserves a
|
||||
System the moment two records would share it, the same two-or-more test
|
||||
snippets use; don't wait to be asked to name an area that plainly exists.
|
||||
Read a subsystem back with list_system_records, or search(system_id=...) for
|
||||
a ranked cut.
|
||||
- Reference note vs dev-log — STATE vs CHRONICLE. A dev-log records what
|
||||
HAPPENED: write it once, never rewrite it. A durable finding — how a
|
||||
subsystem works, a measured number, an architecture fact — belongs in that
|
||||
System's REFERENCE NOTE ("«System name» — reference", tagged to the System),
|
||||
which is UPDATED IN PLACE as the facts change. Updating loses nothing: every
|
||||
meaningful edit is snapshotted (note versions are the changelog). Create the
|
||||
reference note if the System lacks one; update it if it exists; have the
|
||||
dev-log [[link]] it rather than restating state. State smeared across dated
|
||||
logs is unreachable by search — sixteen near-identical dev-logs tie, and no
|
||||
ranking can pick the right one, because no right one exists.
|
||||
|
||||
Mechanics:
|
||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||
@@ -83,6 +102,16 @@ not something you wait to be asked for:
|
||||
record (update_note / update_task / add_task_log) rather than duplicating.
|
||||
Only pass force=true when it's genuinely a distinct record — a duplicate both
|
||||
bloats the store and surfaces as a stale competing copy in later searches.
|
||||
- When a note genuinely IS new but overtakes an older one, say so: pass the
|
||||
older note's id in `supersedes` on create_note / update_note. Reach for it on
|
||||
a re-measurement, a decision that reverses an earlier one, a dev-log covering
|
||||
ground a previous one covered. The old note stays readable and keeps its
|
||||
place; it stops competing for the same question and arrives labelled. This is
|
||||
the third answer alongside update-instead and force: not everything that
|
||||
resembles an existing record should be folded into it, and not everything
|
||||
distinct should compete with it forever. If a result carries `superseded_by`,
|
||||
a later note claims to have brought it up to date — read it as what was true
|
||||
when written and open the newer one before acting.
|
||||
- Scope to the project in scope. When a project is active (you called
|
||||
enter_project), pass its project_id to search / list_tasks / list_notes so
|
||||
results stay inside that project. Querying with no project_id pulls in every
|
||||
|
||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
@@ -55,12 +56,42 @@ async def list_notes(
|
||||
return {"notes": [n.to_dict() for n in rows], "total": total}
|
||||
|
||||
|
||||
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
"""Add both directions of the supersession relation to a note payload.
|
||||
|
||||
Both, because they answer different questions and only one of them is
|
||||
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
|
||||
READER needs and what the note itself cannot know — a stale record handed
|
||||
over without that marker gets acted on confidently, which is worse than
|
||||
never surfacing it.
|
||||
|
||||
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
|
||||
permanently-empty lists. A field that always says nothing trains readers to
|
||||
skip fields, which is the lesson `consolidated_at` cost us (#2483).
|
||||
"""
|
||||
rel = await supersession_svc.get_relations(uid, note_id)
|
||||
if rel["supersedes"]:
|
||||
data["supersedes"] = rel["supersedes"]
|
||||
if rel["superseded_by"]:
|
||||
data["superseded_by"] = rel["superseded_by"]
|
||||
data["superseded_note"] = (
|
||||
"A later note claims to bring this up to date — see superseded_by. "
|
||||
"Read this as what was true when written, and check the newer one "
|
||||
"before acting on it."
|
||||
)
|
||||
|
||||
|
||||
async def get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Scribe note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
A note another user shared with you also carries `shared`, `owner` and
|
||||
`permission` — read it as their suggestion, not as settled practice you set.
|
||||
|
||||
IF THE RESULT CARRIES `superseded_by`, a later note claims to have brought
|
||||
this one up to date. It is still here and still readable — supersession
|
||||
demotes, it never hides — but read it as what was true when written, and
|
||||
open the newer note before acting on it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
||||
@@ -74,6 +105,7 @@ async def get_note(note_id: int) -> dict:
|
||||
# snippets would leave those permanently at zero pulls and make them look
|
||||
# like dead weight next to snippets that merely had a counter (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -83,6 +115,7 @@ async def create_note(
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
supersedes: list[int] | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new note in Scribe.
|
||||
@@ -94,6 +127,15 @@ async def create_note(
|
||||
project_id: Associate with a project (use 0 for no project / orphan note).
|
||||
system_ids: Ids of the project's Systems to associate this note with
|
||||
(e.g. research about a subsystem). See list_systems / create_system.
|
||||
supersedes: Ids of EARLIER notes this one replaces or brings up to date.
|
||||
Reach for it whenever you write something that overtakes what an
|
||||
older note recorded — a re-measurement, a decision that reverses an
|
||||
earlier one, a dev-log covering ground a previous one covered.
|
||||
The older note stays readable and keeps its place in search; it
|
||||
simply stops competing with this one for the same question, and
|
||||
arrives labelled when it does surface. This records a CLAIM, not a
|
||||
verdict: it never says the older note was wrong, only that it is no
|
||||
longer the current answer.
|
||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||
meaning-similar note already exists in the same project, creation is
|
||||
BLOCKED and the existing note's id is returned so you update it
|
||||
@@ -121,11 +163,19 @@ async def create_note(
|
||||
)
|
||||
if system_ids:
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
if supersedes:
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note.id, supersedes)
|
||||
except PermissionError as exc:
|
||||
# The note WAS created — surface the real reason rather than a
|
||||
# not-found, and leave the note rather than silently rolling it back.
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
await _attach_supersession(uid, note.id, data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -136,6 +186,7 @@ async def update_note(
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
supersedes: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
||||
|
||||
@@ -147,6 +198,9 @@ async def update_note(
|
||||
project_id: New project association. Omit (or pass 0) to leave unchanged.
|
||||
system_ids: Replace this note's System associations with these ids
|
||||
(set-semantics). None = leave unchanged; [] = clear all.
|
||||
supersedes: Replace the ids of earlier notes this one replaces
|
||||
(set-semantics). None = leave unchanged; [] = clear all. See
|
||||
create_note for when to reach for it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -163,11 +217,17 @@ async def update_note(
|
||||
raise ValueError(f"note {note_id} not found")
|
||||
if system_ids is not None:
|
||||
await systems_svc.set_record_systems(uid, note_id, system_ids)
|
||||
if supersedes is not None:
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note_id, supersedes)
|
||||
except PermissionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
|
||||
]
|
||||
await _attach_supersession(uid, note_id, data)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
|
||||
|
||||
@@ -54,7 +55,14 @@ async def enter_project(project_id: int) -> dict:
|
||||
|
||||
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||
open_tasks, recent_notes, design_system.
|
||||
open_tasks, recent_notes, design_system, systems.
|
||||
|
||||
`systems` is the project's vocabulary of named subsystems/areas. It is
|
||||
returned here so you can TAG as you write: when creating or meaningfully
|
||||
updating a record, ask which of these areas it is about and pass their ids
|
||||
as `system_ids`. If the area a record describes is missing from this list,
|
||||
create it with create_system rather than leaving the area unmodelled. Read
|
||||
a subsystem's accumulated records with list_system_records.
|
||||
|
||||
`design_system` is null unless the project points at one. When present it
|
||||
carries the chain-merged guidance (the house style AND this project's
|
||||
@@ -81,6 +89,11 @@ async def enter_project(project_id: int) -> dict:
|
||||
uid, is_task=False, project_id=project_id,
|
||||
sort="updated_at", limit=5,
|
||||
)
|
||||
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
|
||||
# records to Systems" is only executable if the list is in front of the
|
||||
# agent when it writes — which it never was, and tagging stopped within
|
||||
# three days of the feature landing (#2546's audit).
|
||||
systems = await systems_svc.list_systems(uid, project_id)
|
||||
# A project need not have one, and most installs won't — null is ordinary
|
||||
# here, not a missing prerequisite.
|
||||
design_system = None
|
||||
@@ -91,6 +104,15 @@ async def enter_project(project_id: int) -> dict:
|
||||
|
||||
return {
|
||||
"project": project.to_dict(),
|
||||
# Trimmed to what tagging needs. The full charter is get_system's job —
|
||||
# this list rides along on every session start, so it stays lean.
|
||||
"systems": [
|
||||
{
|
||||
"id": s.id, "name": s.name,
|
||||
"description": (s.description or "").split("\n")[0][:200],
|
||||
}
|
||||
for s in systems
|
||||
],
|
||||
"design_system": design_system,
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
|
||||
@@ -20,6 +20,7 @@ async def search(
|
||||
content_type: str = "all",
|
||||
limit: int = 10,
|
||||
project_id: int = 0,
|
||||
system_id: int = 0,
|
||||
) -> dict:
|
||||
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
|
||||
|
||||
@@ -39,6 +40,11 @@ async def search(
|
||||
enter_project) — otherwise this searches across ALL projects and
|
||||
bleeds unrelated work into the result set. 0 = search everything
|
||||
(use only when you genuinely want a cross-project sweep).
|
||||
system_id: Narrow to records tagged to one System (a named
|
||||
subsystem/area — enter_project lists them). Use when investigating
|
||||
a specific subsystem: it cuts the candidates to records someone
|
||||
deliberately filed under that area. 0 = no system filter.
|
||||
list_system_records gives the same slice unranked.
|
||||
|
||||
Returns:
|
||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
||||
@@ -55,6 +61,7 @@ async def search(
|
||||
raw = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task,
|
||||
project_id=project_id or None,
|
||||
system_id=system_id or None,
|
||||
# An explicit search reaches everything the operator may read, including
|
||||
# records shared with them one-to-one.
|
||||
scope="read",
|
||||
|
||||
@@ -116,7 +116,14 @@ async def update_system(
|
||||
async def list_system_records(
|
||||
system_id: int, kind: str = "", open_only: bool = False
|
||||
) -> dict:
|
||||
"""List records associated with a System.
|
||||
"""Everything filed under one System — the way to READ a subsystem.
|
||||
|
||||
Reach for this when investigating a specific area: it returns the notes,
|
||||
tasks, issues and snippets someone deliberately tagged to it — the
|
||||
subsystem's accumulated record, unranked. Start with its reference note if
|
||||
one exists (titled "«System» — reference"); that is the living state, and
|
||||
the rest is history and open work around it. For a ranked cut of the same
|
||||
slice, search(system_id=...) filters semantic search to this association.
|
||||
|
||||
Args:
|
||||
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
||||
|
||||
@@ -33,6 +33,7 @@ from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
||||
from scribe.models.note_version import NoteVersion # noqa: E402, F401
|
||||
from scribe.models.note_supersession import NoteSupersession # noqa: E402, F401
|
||||
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
||||
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
from scribe.models.notification import Notification # noqa: E402, F401
|
||||
|
||||
@@ -33,9 +33,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consolidated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
@@ -101,9 +98,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"description": self.description,
|
||||
"consolidated_at": (
|
||||
self.consolidated_at.isoformat() if self.consolidated_at else None
|
||||
),
|
||||
"tags": self.tags or [],
|
||||
"parent_id": self.parent_id,
|
||||
"arose_from_id": self.arose_from_id,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class NoteSupersession(Base, CreatedAtMixin):
|
||||
"""A newer record's claim that it has overtaken an older one.
|
||||
|
||||
WHY THE RELATION POINTS FORWARD
|
||||
|
||||
The note being WRITTEN declares what it supersedes. The old record cannot
|
||||
know it has been overtaken — asking it to record its own obsolescence is
|
||||
asking it to predict the future. So the claim is made by the party that has
|
||||
the knowledge, and the demotion is derived from the far end.
|
||||
|
||||
WHY A TABLE RATHER THAN A COLUMN
|
||||
|
||||
It is genuinely many-to-many and partial: one note may supersede parts of
|
||||
several others, and a note may be overtaken piecemeal by several later ones.
|
||||
Both directions are queried and neither is rare —
|
||||
`superseded_id` answers the ranking question ("has this been overtaken?"),
|
||||
`superseder_id` answers the record view ("what does this replace?"). An
|
||||
array column on `notes` could be indexed for one and not the other.
|
||||
|
||||
WHAT IT MEANS, AND WHAT IT DOES NOT
|
||||
|
||||
A claim, never a proof. Supersession DEMOTES a record in ranked retrieval;
|
||||
it does not assert the older record was wrong, and it never hides it. A note
|
||||
that accurately described how something worked in June is still accurate
|
||||
about June — it is just no longer the answer to "how does this work".
|
||||
|
||||
CASCADE IS SAFE HERE BECAUSE TRASHING IS NOT A DELETE
|
||||
|
||||
`trash_svc` stamps `deleted_at` (an UPDATE), so a trashed note keeps its
|
||||
claims and `restore` brings them back intact. The cascade fires only on
|
||||
`purge_trash`, where the row genuinely goes — and a supersession claim about
|
||||
a row that no longer exists is not a fact anyone can act on.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_supersessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# The newer record, making the claim.
|
||||
superseder_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="CASCADE")
|
||||
)
|
||||
# The older record, demoted by it.
|
||||
superseded_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||
),
|
||||
# Both directions indexed — see the class docstring for why neither is
|
||||
# the rare one.
|
||||
Index("ix_note_supersessions_superseder", "superseder_id"),
|
||||
Index("ix_note_supersessions_superseded", "superseded_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"superseder_id": self.superseder_id,
|
||||
"superseded_id": self.superseded_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -22,7 +22,25 @@ from scribe.services.notes import (
|
||||
update_note,
|
||||
)
|
||||
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
||||
from scribe.services import supersession as supersession_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
|
||||
|
||||
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
"""Both directions of the supersession relation on a note payload.
|
||||
|
||||
Mirrors the MCP helper of the same name — the two surfaces must agree about
|
||||
what a note's payload says, or the web UI and the agent would disagree about
|
||||
whether a record is current.
|
||||
|
||||
Omitted when empty: a field that always says nothing trains readers to skip
|
||||
fields, which is what `consolidated_at` cost (#2483).
|
||||
"""
|
||||
rel = await supersession_svc.get_relations(uid, note_id)
|
||||
if rel["supersedes"]:
|
||||
data["supersedes"] = rel["supersedes"]
|
||||
if rel["superseded_by"]:
|
||||
data["superseded_by"] = rel["superseded_by"]
|
||||
from scribe.services.note_versions import list_versions, get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -112,7 +130,19 @@ async def create_note_route():
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
return jsonify(note.to_dict()), 201
|
||||
|
||||
# Same capability as the MCP create path (#33). Without it the web UI would
|
||||
# be the surface on which a supersession claim silently cannot be made.
|
||||
if data.get("supersedes"):
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note.id, data["supersedes"])
|
||||
except PermissionError as exc:
|
||||
# 403, not 400: the request is well-formed and the caller simply
|
||||
# may not write the target. The note itself was created.
|
||||
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
|
||||
out = note.to_dict()
|
||||
await _attach_supersession(uid, note.id, out)
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@notes_bp.route("/tags", methods=["GET"])
|
||||
@@ -186,6 +216,7 @@ async def get_note_route(note_id: int):
|
||||
# injected line useful?" is answered by agent pulls alone, and a human
|
||||
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
|
||||
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
|
||||
await _attach_supersession(uid, note_id, data)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -224,7 +255,18 @@ async def update_note_route(note_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
# Set-semantics, matching MCP and the PATCH route: present-and-empty
|
||||
# clears, absent leaves alone. Scoped by the CALLER, not owner_uid — an
|
||||
# editor-share holder may edit this note and must not thereby inherit the
|
||||
# owner's write access to whatever they name as superseded (#47).
|
||||
if "supersedes" in data:
|
||||
try:
|
||||
await supersession_svc.set_supersedes(uid, note_id, data["supersedes"] or [])
|
||||
except PermissionError as exc:
|
||||
return jsonify({"error": str(exc)}), 403
|
||||
out = note.to_dict()
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
||||
|
||||
@@ -34,10 +34,15 @@ async def search_route():
|
||||
content_type = request.args.get("content_type", "all")
|
||||
limit = min(request.args.get("limit", 10, type=int), 50)
|
||||
is_task = _content_type_to_is_task(content_type)
|
||||
# Same association filter the MCP tool takes (#33). The project filter this
|
||||
# route is still missing is #2463's — it carries a default-scope UI decision
|
||||
# this change must not preempt.
|
||||
system_id = request.args.get("system_id", type=int)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
results = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||
system_id=system_id,
|
||||
# The user typed this, so it reaches everything they may read.
|
||||
scope="read",
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from scribe.models import async_session
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
@@ -32,8 +33,11 @@ logger = logging.getLogger(__name__)
|
||||
# when the calendar surface was retired — old v3 events are skipped on restore.
|
||||
# v5 (2026-08) added the six tables that had accumulated outside the backup
|
||||
# entirely (#2293), and the coverage guard that stops the seventh.
|
||||
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
|
||||
# the table shipped without a backup section and the coverage test failed the
|
||||
# build, which is the whole reason that list was written.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 5
|
||||
BACKUP_VERSION = 6
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -50,7 +54,7 @@ _BACKED_UP = [
|
||||
"project_topic_suppressions",
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"note_usage_events", "repo_bindings",
|
||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -110,6 +114,17 @@ def _record_system_rows(rows) -> list[dict]:
|
||||
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
|
||||
|
||||
|
||||
def _note_supersession_rows(rows) -> list[dict]:
|
||||
"""Which record has overtaken which. Carried because it is a JUDGEMENT —
|
||||
someone decided this note replaced that one, and nothing in either note's
|
||||
text records the decision. Lose it and the corpus silently reverts to
|
||||
ranking stale material alongside current material."""
|
||||
return [
|
||||
{"superseder_id": r.superseder_id, "superseded_id": r.superseded_id}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _design_system_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
@@ -171,6 +186,9 @@ async def export_full_backup() -> dict:
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
systems = (await session.execute(select(System))).scalars().all()
|
||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||
supersessions = (
|
||||
await session.execute(select(NoteSupersession))
|
||||
).scalars().all()
|
||||
# Parent-first, so a restore can resolve parent_id as it goes rather
|
||||
# than needing a second pass — the self-FK is the only ordering
|
||||
# constraint in this payload.
|
||||
@@ -354,6 +372,7 @@ async def export_full_backup() -> dict:
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +415,17 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
record_systems = (await session.execute(
|
||||
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
|
||||
)).scalars().all() if system_ids else []
|
||||
# BOTH ends must be this user's notes. A claim spanning out to someone
|
||||
# else's record cannot be restored into a single-user import — the far
|
||||
# id would not be in the map — so carrying it would export a row that
|
||||
# silently vanishes on the way back in. Whole-instance backups have no
|
||||
# such problem and take every row.
|
||||
supersessions = (await session.execute(
|
||||
select(NoteSupersession).where(
|
||||
NoteSupersession.superseder_id.in_(note_ids),
|
||||
NoteSupersession.superseded_id.in_(note_ids),
|
||||
)
|
||||
)).scalars().all() if note_ids else []
|
||||
design_systems = (await session.execute(
|
||||
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
|
||||
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
|
||||
@@ -597,6 +627,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
}
|
||||
|
||||
|
||||
@@ -700,6 +731,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"note_supersessions": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -990,6 +1022,24 @@ async def _restore_v2(data: dict) -> dict:
|
||||
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
|
||||
stats["record_systems"] += 1
|
||||
|
||||
# 16b. Supersession claims. Guarded by `data.get` like every other
|
||||
# post-v2 section, so a v5 or older payload restores cleanly without it.
|
||||
#
|
||||
# Both ends must map. A claim is about a PAIR — half of one is not a
|
||||
# weaker claim, it is a dangling row pointing at whatever note happens
|
||||
# to hold that id next.
|
||||
for sup in data.get("note_supersessions", []):
|
||||
mapped_new = note_id_map.get(sup.get("superseder_id", 0))
|
||||
mapped_old = note_id_map.get(sup.get("superseded_id", 0))
|
||||
if mapped_new is None or mapped_old is None or mapped_new == mapped_old:
|
||||
continue
|
||||
session.add(
|
||||
NoteSupersession(
|
||||
superseder_id=mapped_new, superseded_id=mapped_old
|
||||
)
|
||||
)
|
||||
stats["note_supersessions"] += 1
|
||||
|
||||
# 17. Design systems. The export orders these parent-first, so a
|
||||
# parent's new id is always in the map by the time a child needs it —
|
||||
# no second pass, and a child whose parent is missing lands as a root
|
||||
|
||||
@@ -258,7 +258,13 @@ async def find_duplicate_note(
|
||||
|
||||
# --- Signal 3: semantic similarity (only with a substantial body) ---
|
||||
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
|
||||
query = f"{title}\n{body}".strip()
|
||||
# Built by the SAME function the corpus was embedded with. This one is
|
||||
# the copy that mattered most and was easiest to miss: it is a QUERY
|
||||
# document, compared against embedded ones. Shaped differently from the
|
||||
# corpus it searches, the gate degrades silently — it still returns
|
||||
# neighbours, just less apt ones, and no signal says the query and the
|
||||
# index stopped agreeing (found by the guard in test_embedding_text).
|
||||
query = embeddings_svc.embedding_text(title, body)
|
||||
# Scope the semantic check the same way as the title check: a record in
|
||||
# project P compares only to P; a project-less (orphan) record compares
|
||||
# only to other orphans (orphan_only), NOT across every project — without
|
||||
@@ -275,6 +281,12 @@ async def find_duplicate_note(
|
||||
# would refuse their write and point them at something they may not
|
||||
# be able to edit.
|
||||
scope="own",
|
||||
# NOT demoted by supersession (#278). A superseded record is still a
|
||||
# duplicate of what you are about to write — the claim is that it is
|
||||
# no longer CURRENT, not that it is gone. Demoting it here would let
|
||||
# the same note be recorded a second time, and the second copy would
|
||||
# be the one nothing warns about.
|
||||
demote_superseded=False,
|
||||
)
|
||||
for score, note in hits:
|
||||
# semantic_search_notes doesn't filter note_type — enforce it here so
|
||||
|
||||
@@ -86,6 +86,94 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
return dot / (mag_a * mag_b)
|
||||
|
||||
|
||||
# How much a superseded record is pushed down the ranking (#278).
|
||||
#
|
||||
# Chosen against a measurement, not by feel. On 2026-08-07 dev-log #2420 sat at
|
||||
# 0.6120 on a query made of its own title phrase, 8th, behind #1759 at 0.6506 —
|
||||
# a deficit of 0.039 to the top and ~0.014 to its nearest neighbours. A penalty
|
||||
# of 0.05 clears that whole band, so demoting a cluster's stale members actually
|
||||
# reorders it rather than shuffling within a tie.
|
||||
#
|
||||
# It is deliberately NOT large. Supersession is a claim about SOME of a record's
|
||||
# content, so a superseded note that strongly answers a question nothing else
|
||||
# answers should still surface — just behind anything comparable that is
|
||||
# current. A penalty big enough to bury it outright would be hiding by another
|
||||
# name, which is the thing the operator ruled out.
|
||||
_SUPERSESSION_PENALTY = 0.05
|
||||
|
||||
# Candidates fetched per requested result when a re-rank follows. Three ranks of
|
||||
# headroom is far more than a 0.05 penalty can move anything through in a corpus
|
||||
# whose neighbours sit ~0.01-0.02 apart.
|
||||
_SUPERSESSION_OVERFETCH = 3
|
||||
|
||||
|
||||
async def _apply_supersession_penalty(
|
||||
scored: list[tuple[float, "Note"]], limit: int
|
||||
) -> list[tuple[float, "Note"]]:
|
||||
"""Push superseded records below their equals, then take the top `limit`.
|
||||
|
||||
The penalty is applied to the RANKING score and the returned score, so
|
||||
downstream gates see the adjusted value — the auto-inject margin band in
|
||||
particular, which exists to stop near-ties dragging in neighbours and would
|
||||
otherwise re-tie exactly what this just separated.
|
||||
|
||||
It is NOT applied to the relevance threshold: the floor decides whether a
|
||||
record is relevant at all, the penalty decides which relevant record comes
|
||||
first. Applying it to the floor would drop a superseded record out of the
|
||||
results entirely — hiding, which is the one thing this must not do.
|
||||
|
||||
Stable within a tie: Python's sort preserves the distance order the database
|
||||
already established, so equal-scoring records keep their original sequence
|
||||
rather than reshuffling per call.
|
||||
"""
|
||||
if not scored:
|
||||
return []
|
||||
from scribe.services.supersession import superseded_ids
|
||||
|
||||
try:
|
||||
stale = await superseded_ids([int(note.id) for _score, note in scored])
|
||||
except Exception:
|
||||
# Fail OPEN, and the direction matters: ranking without the penalty is
|
||||
# the behaviour that shipped for months. Returning nothing, or raising,
|
||||
# would turn a supersession-lookup hiccup into a broken search.
|
||||
logger.warning("Supersession lookup failed — ranking unpenalised", exc_info=True)
|
||||
return scored[:limit]
|
||||
|
||||
if not stale:
|
||||
return scored[:limit]
|
||||
adjusted = [
|
||||
(score - _SUPERSESSION_PENALTY if int(note.id) in stale else score, note)
|
||||
for score, note in scored
|
||||
]
|
||||
adjusted.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return adjusted[:limit]
|
||||
|
||||
|
||||
def embedding_text(title: str | None, body: str | None) -> str:
|
||||
"""The document a record is embedded AS.
|
||||
|
||||
One definition, deliberately. This was written out three times — the write
|
||||
path (`notes.embed_note`), the recurring-task spawn, and the startup
|
||||
backfill — and identical copies of a formatting rule are three chances to
|
||||
change one and not the others. The spawn path is the dangerous one: a
|
||||
recurring task embedded to a different shape than everything else would be
|
||||
ranked against a corpus it doesn't match, and nothing would report it.
|
||||
|
||||
It is also a PRECONDITION for changing the shape at all (#2486). Measured,
|
||||
a dev-log's vector separates from five unrelated dev-logs by 0.023 while a
|
||||
snippet's separates by 0.153 — the difference being that a snippet states
|
||||
its purpose twice in a short document, so the purpose dominates. Testing an
|
||||
alternative shape against three copies would mean testing a shape that is
|
||||
not the one in production.
|
||||
|
||||
Whether `title\\n{body}` is the RIGHT shape is the open question. That it is
|
||||
one shape is what makes the question answerable.
|
||||
"""
|
||||
title = title or ""
|
||||
body = body or ""
|
||||
return f"{title}\n{body}".strip() if body else title
|
||||
|
||||
|
||||
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
|
||||
"""Generate and persist an embedding for a note. Safe to fire-and-forget."""
|
||||
if not text or not text.strip():
|
||||
@@ -120,6 +208,8 @@ async def semantic_search_notes(
|
||||
task_kind: str | Sequence[str] | None = None,
|
||||
orphan_only: bool = False,
|
||||
scope: str = "own",
|
||||
demote_superseded: bool = True,
|
||||
system_id: int | None = None,
|
||||
) -> list[tuple[float, Note]]:
|
||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||
|
||||
@@ -151,6 +241,13 @@ async def semantic_search_notes(
|
||||
so a similarity floor of *threshold* is a distance ceiling of
|
||||
``1 - threshold`` and similarity is recovered as ``1 - distance``.
|
||||
|
||||
`demote_superseded` applies the supersession penalty (#278): a record a
|
||||
later note claims to have overtaken ranks below its equals. Callers asking
|
||||
"what is the current answer" want it; the near-duplicate gate does NOT, and
|
||||
passes False — a superseded record is still a duplicate of what you are
|
||||
about to write, and demoting it there would let the same note be recorded
|
||||
twice, the second time invisibly.
|
||||
|
||||
Returns an empty list if the embedder is unavailable or on any error.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
@@ -185,6 +282,19 @@ async def semantic_search_notes(
|
||||
stmt = stmt.where(Note.project_id.is_(None))
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Note.project_id == project_id)
|
||||
# Narrow to records tagged to one System (subsystem/area). An
|
||||
# association filter, not a ranking signal — membership in the
|
||||
# candidate set, decided before scoring, like project_id above.
|
||||
if system_id is not None:
|
||||
from scribe.models.system import RecordSystem
|
||||
stmt = stmt.where(
|
||||
select(RecordSystem.id)
|
||||
.where(
|
||||
RecordSystem.note_id == Note.id,
|
||||
RecordSystem.system_id == system_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
if is_task is True:
|
||||
stmt = stmt.where(Note.status.isnot(None))
|
||||
elif is_task is False:
|
||||
@@ -207,14 +317,38 @@ async def semantic_search_notes(
|
||||
)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
||||
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
|
||||
# OVER-FETCH when a re-rank follows, so the demotion can actually
|
||||
# move something. Demoting after a LIMIT k would be theatre: the cut
|
||||
# already happened, so a superseded record pushed down still sits in
|
||||
# the results and the live record that should have replaced it was
|
||||
# never fetched.
|
||||
#
|
||||
# Ordering stays on RAW distance so pgvector's HNSW index still
|
||||
# serves it (migration 0067). Ordering by `distance + penalty`
|
||||
# instead would be exact, and would turn an indexed top-k into a
|
||||
# scan-and-sort of every embedded note.
|
||||
#
|
||||
# The cost of that trade, stated plainly: a live record outside the
|
||||
# over-fetch window cannot be promoted into the results. With a
|
||||
# penalty far smaller than the window's score spread, that case
|
||||
# needs the true answer to be more than _SUPERSESSION_OVERFETCH
|
||||
# ranks down, which no observed query comes close to.
|
||||
fetch = limit * _SUPERSESSION_OVERFETCH if demote_superseded else limit
|
||||
stmt = (
|
||||
stmt.where(distance <= max_distance)
|
||||
.order_by(distance.asc())
|
||||
.limit(fetch)
|
||||
)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
except Exception:
|
||||
logger.warning("Failed to query note embeddings", exc_info=True)
|
||||
return []
|
||||
|
||||
# Recover similarity (1 - distance) and preserve the highest-first contract.
|
||||
return [(1.0 - float(dist), note) for note, dist in rows]
|
||||
scored = [(1.0 - float(dist), note) for note, dist in rows]
|
||||
if not demote_superseded:
|
||||
return scored[:limit]
|
||||
return await _apply_supersession_penalty(scored, limit)
|
||||
|
||||
|
||||
async def backfill_note_embeddings() -> None:
|
||||
@@ -248,7 +382,7 @@ async def backfill_note_embeddings() -> None:
|
||||
logger.info("Embedding backfill: generating embeddings for %d notes", len(notes_to_embed))
|
||||
success = 0
|
||||
for note_id, user_id, title, body in notes_to_embed:
|
||||
text = f"{title}\n{body}".strip() if body else (title or "")
|
||||
text = embedding_text(title, body)
|
||||
if not text:
|
||||
continue
|
||||
await upsert_note_embedding(note_id, user_id, text)
|
||||
|
||||
@@ -30,13 +30,13 @@ def embed_note(note) -> None:
|
||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||
not an error.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.embeddings import embedding_text, upsert_note_embedding
|
||||
text = embedding_text(note.title, note.body)
|
||||
if not text:
|
||||
return
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
except RuntimeError:
|
||||
pass # no running loop — a sync caller, not a failure
|
||||
@@ -373,17 +373,16 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
return note
|
||||
|
||||
|
||||
async def delete_note(user_id: int, note_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
return False
|
||||
await session.delete(note)
|
||||
await session.commit()
|
||||
return True
|
||||
# A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
|
||||
# removed with #278 step 1. It is recorded rather than silently dropped because
|
||||
# the danger was never that it ran — it is that it was findable by name. Someone
|
||||
# wanting to delete a note greps `delete_note`, finds a function in the notes
|
||||
# service with exactly the right signature, and permanently destroys a record
|
||||
# every path downstream expects to be recoverable.
|
||||
#
|
||||
# The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
|
||||
# descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
|
||||
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
||||
|
||||
|
||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||
|
||||
@@ -31,6 +31,7 @@ from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
from scribe.services.note_usage import record_surfaced
|
||||
from scribe.services.supersession import superseded_ids
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
@@ -425,11 +426,19 @@ async def build_autoinject_hint(
|
||||
"`get_note(id)`, or `get_snippet` / `get_process` for those kinds "
|
||||
"(titles only; injected once per session):",
|
||||
]
|
||||
# A superseded record is DEMOTED, not removed (#278) — so one can still reach
|
||||
# this menu, and when it does the reader has to be told. An agent handed
|
||||
# stale material with nothing marking it acts on it with full confidence,
|
||||
# which is worse than never having surfaced it. One query for the whole menu.
|
||||
stale = await superseded_ids([int(n.id) for _s, n in kept])
|
||||
|
||||
note_ids: list[int] = []
|
||||
for score, note in kept:
|
||||
note_ids.append(int(note.id))
|
||||
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
||||
line = f"> - #{note.id} [{_record_kind(note)}] \"{title}\" ({score:.2f})"
|
||||
if int(note.id) in stale:
|
||||
line += " — SUPERSEDED, a later record covers this; check that first"
|
||||
if note.user_id != user_id:
|
||||
who = owners.get(int(note.user_id)) or "another user"
|
||||
line += f" — shared by {who}, treat as a suggestion"
|
||||
|
||||
@@ -103,7 +103,7 @@ async def spawn_recurring_tasks() -> int:
|
||||
|
||||
Returns the number of tasks spawned.
|
||||
"""
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.embeddings import embedding_text, upsert_note_embedding
|
||||
from scribe.services.notes import create_note
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -139,7 +139,7 @@ async def spawn_recurring_tasks() -> int:
|
||||
milestone_id=task.milestone_id,
|
||||
recurrence_rule=task.recurrence_rule,
|
||||
)
|
||||
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
|
||||
text = embedding_text(child.title, child.body)
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Which records have been overtaken by which — the claim, not the ranking.
|
||||
|
||||
Step 2 of milestone #278. This module only records and reads the relation; the
|
||||
demotion that makes it matter lives in the retrieval layer.
|
||||
|
||||
WHY THE CLAIM POINTS FORWARD
|
||||
|
||||
The note being written declares what it supersedes. The older record cannot
|
||||
know it has been overtaken — asking it to record its own obsolescence is asking
|
||||
it to predict the future. So the party with the knowledge makes the claim, and
|
||||
"has this been superseded?" is derived by looking at the far end.
|
||||
|
||||
WHAT IT MEANS
|
||||
|
||||
A claim, never a proof. It demotes a record in ranked retrieval; it does not
|
||||
assert the older record was wrong and it never hides it. A note that accurately
|
||||
described how something worked in June is still accurate about June.
|
||||
|
||||
Partial and many-to-many by nature: one note may supersede parts of several
|
||||
others, and be overtaken piecemeal by several later ones.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.services import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _closes_a_cycle(session, superseder_id: int, superseded_id: int) -> bool:
|
||||
"""True if `superseder -> superseded` would complete a loop.
|
||||
|
||||
Walks the existing graph from `superseded_id` following superseder→superseded
|
||||
edges. If the walk reaches `superseder_id`, the new edge closes a cycle.
|
||||
|
||||
Why refuse rather than tolerate: a cycle claims every member is obsolete, and
|
||||
under FLAT demotion (see the milestone) that demotes all of them equally —
|
||||
so a set of records that supersede each other in a ring would vanish from
|
||||
ranked retrieval together, which is the opposite of the intent. Nothing about
|
||||
the data would say why.
|
||||
|
||||
Iterative with a visited set, not recursion: the graph is user-supplied and
|
||||
a deep chain must not become a stack overflow in a write path.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
frontier = [superseded_id]
|
||||
while frontier:
|
||||
current = frontier.pop()
|
||||
if current == superseder_id:
|
||||
return True
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
rows = (await session.execute(
|
||||
select(NoteSupersession.superseded_id)
|
||||
.where(NoteSupersession.superseder_id == current)
|
||||
)).scalars().all()
|
||||
frontier.extend(int(r) for r in rows)
|
||||
return False
|
||||
|
||||
|
||||
async def set_supersedes(
|
||||
user_id: int, note_id: int, superseded_ids: list[int]
|
||||
) -> list[int] | None:
|
||||
"""Replace what `note_id` claims to supersede (set semantics).
|
||||
|
||||
Returns the resulting list, or None if the caller cannot write the note
|
||||
making the claim.
|
||||
|
||||
WHAT IS SILENTLY DROPPED, and why each is a drop rather than an error:
|
||||
- ids that don't exist or are trashed — the claim has no subject
|
||||
- the note's own id — meaningless, and the DB CHECK would refuse it anyway
|
||||
- an id that would close a cycle — see _closes_a_cycle
|
||||
|
||||
WHAT IS REFUSED OUTRIGHT: a target the caller cannot WRITE. That is not a
|
||||
silent drop, because it is the one case where the caller might reasonably
|
||||
believe they succeeded and be wrong in a way that matters — demoting someone
|
||||
else's record out of their retrieval is damage you cannot see from the
|
||||
outside. Rule #47.
|
||||
"""
|
||||
if not await access.can_write_note(user_id, note_id):
|
||||
return None
|
||||
|
||||
async with async_session() as session:
|
||||
wanted: list[int] = []
|
||||
for target in dict.fromkeys(superseded_ids): # de-dup, keep order
|
||||
target = int(target)
|
||||
if target == note_id:
|
||||
continue
|
||||
note = await session.get(Note, target)
|
||||
if note is None or note.deleted_at is not None:
|
||||
continue
|
||||
if not await access.can_write_note(user_id, target):
|
||||
raise PermissionError(
|
||||
f"note {target} is not yours to supersede — you need write "
|
||||
f"access to it, not just read. Superseding demotes a record "
|
||||
f"in its owner's retrieval too."
|
||||
)
|
||||
if await _closes_a_cycle(session, note_id, target):
|
||||
continue
|
||||
wanted.append(target)
|
||||
|
||||
existing = set((await session.execute(
|
||||
select(NoteSupersession.superseded_id)
|
||||
.where(NoteSupersession.superseder_id == note_id)
|
||||
)).scalars().all())
|
||||
wanted_set = set(wanted)
|
||||
|
||||
to_remove = existing - wanted_set
|
||||
if to_remove:
|
||||
await session.execute(
|
||||
delete(NoteSupersession).where(
|
||||
NoteSupersession.superseder_id == note_id,
|
||||
NoteSupersession.superseded_id.in_(to_remove),
|
||||
)
|
||||
)
|
||||
for target in wanted:
|
||||
if target not in existing:
|
||||
session.add(
|
||||
NoteSupersession(superseder_id=note_id, superseded_id=target)
|
||||
)
|
||||
await session.commit()
|
||||
return wanted
|
||||
|
||||
|
||||
async def get_relations(user_id: int, note_id: int) -> dict[str, list[int]]:
|
||||
"""Both directions for one note: what it supersedes, and what supersedes it.
|
||||
|
||||
ONE query and ONE ACL check, because this runs on every note read. Asking
|
||||
the two questions separately doubled the round trips on the hottest path in
|
||||
the product to save a two-line partition — the wrong trade, and one I made
|
||||
on the first attempt.
|
||||
|
||||
Returns {"supersedes": [...], "superseded_by": [...]}, both sorted. Empty
|
||||
lists when the caller cannot read the note.
|
||||
|
||||
`superseded_by` is the direction that matters to a READER and the one the
|
||||
note itself cannot know. An agent handed a stale record with no marker acts
|
||||
on it confidently, which is worse than never surfacing it at all.
|
||||
"""
|
||||
empty: dict[str, list[int]] = {"supersedes": [], "superseded_by": []}
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return empty
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(
|
||||
NoteSupersession.superseder_id, NoteSupersession.superseded_id
|
||||
).where(
|
||||
(NoteSupersession.superseder_id == note_id)
|
||||
| (NoteSupersession.superseded_id == note_id)
|
||||
)
|
||||
)).all()
|
||||
supersedes = sorted(
|
||||
int(old) for new, old in rows if int(new) == note_id
|
||||
)
|
||||
superseded_by = sorted(
|
||||
int(new) for new, old in rows if int(old) == note_id
|
||||
)
|
||||
return {"supersedes": supersedes, "superseded_by": superseded_by}
|
||||
|
||||
|
||||
async def superseded_ids(note_ids: list[int]) -> set[int]:
|
||||
"""Of `note_ids`, which have been superseded by anything. One query.
|
||||
|
||||
Deliberately NOT ACL-scoped: this feeds ranking over a candidate set the
|
||||
caller has already been authorised to see, and re-checking per candidate
|
||||
would be a per-result round trip on a hot path. Callers must pass an
|
||||
already-scoped set — which is why this takes ids rather than a user.
|
||||
"""
|
||||
if not note_ids:
|
||||
return set()
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(NoteSupersession.superseded_id)
|
||||
.where(NoteSupersession.superseded_id.in_(note_ids))
|
||||
)).scalars().all()
|
||||
return {int(r) for r in rows}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""The document shape a record is embedded as, and the guard that keeps it one.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
`f"{title}\\n{body}"` was written out three times — the write path, the
|
||||
recurring-task spawn, and the startup backfill. Identical copies of a formatting
|
||||
rule are three chances to change one and not the others, and the spawn path is
|
||||
the dangerous one: a recurring task embedded to a different shape than the rest
|
||||
of the corpus is ranked against documents it doesn't match, and nothing reports
|
||||
it. A wrong vector returns results; it just returns the wrong ones.
|
||||
|
||||
It is also the precondition for #2486. A dev-log's vector separates from five
|
||||
unrelated dev-logs by 0.023 where a snippet separates by 0.153, and the leading
|
||||
explanation is shape — a snippet states its purpose twice in a short document.
|
||||
Testing an alternative against three copies would mean testing a shape that
|
||||
isn't the one in production.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
from scribe.services.embeddings import embedding_text
|
||||
|
||||
SERVICES = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
|
||||
|
||||
|
||||
def test_title_and_body_are_joined_by_a_newline():
|
||||
assert embedding_text("A title", "A body") == "A title\nA body"
|
||||
|
||||
|
||||
def test_a_bodyless_record_embeds_as_its_title_alone():
|
||||
"""Not "title\\n" — the trailing separator would be a token's worth of noise
|
||||
on the shortest documents, which are the ones least able to spare it."""
|
||||
assert embedding_text("Just a title", "") == "Just a title"
|
||||
assert embedding_text("Just a title", None) == "Just a title"
|
||||
|
||||
|
||||
def test_an_empty_record_yields_an_empty_string():
|
||||
"""Callers gate on falsiness to skip embedding entirely, so this must be
|
||||
empty rather than a stray newline."""
|
||||
assert embedding_text("", "") == ""
|
||||
assert embedding_text(None, None) == ""
|
||||
|
||||
|
||||
def test_surrounding_whitespace_is_stripped():
|
||||
assert embedding_text(" A title ", " A body ") == "A title \n A body"
|
||||
|
||||
|
||||
def test_nothing_else_builds_the_embedding_document_itself():
|
||||
"""The guard. A fourth copy is how the first three happened.
|
||||
|
||||
Source inspection, because this is the shape no behavioural test catches:
|
||||
an inlined copy produces the same string today and diverges silently the day
|
||||
the shape changes. Matches the f-string pattern itself rather than a
|
||||
variable name, so a copy that renames its locals is still caught.
|
||||
"""
|
||||
offenders = []
|
||||
for path in SERVICES.rglob("*.py"):
|
||||
source = path.read_text()
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
# An f-string whose literal parts are exactly a newline, with a
|
||||
# substitution either side: `f"{x}\n{y}"`.
|
||||
literals = [
|
||||
v.value for v in node.values
|
||||
if isinstance(v, ast.Constant) and isinstance(v.value, str)
|
||||
]
|
||||
subs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||
if literals == ["\n"] and len(subs) == 2:
|
||||
offenders.append(f"{path.relative_to(SERVICES)}:{node.lineno}")
|
||||
|
||||
# embeddings.py holds the one definition.
|
||||
offenders = [o for o in offenders if not o.startswith("services/embeddings.py")]
|
||||
assert not offenders, (
|
||||
f"these build the embedding document inline instead of calling "
|
||||
f"embedding_text(): {offenders}. One definition — an inlined copy is "
|
||||
f"ranked against a corpus it no longer matches the moment the shape "
|
||||
f"changes, and nothing reports it (#2486)."
|
||||
)
|
||||
@@ -17,6 +17,21 @@ def _bind_user():
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_supersession():
|
||||
"""Every note read/write now asks for its supersession relations (#278).
|
||||
|
||||
These are unit tests of the TOOL layer and this job has no database — the
|
||||
same hazard the `_fake_note` comment below records for note 2109. Stubbed
|
||||
to "no relations", which is the state of essentially every note; the
|
||||
relation's own behaviour is covered in test_services_supersession.py, and
|
||||
the attachment is covered explicitly below.
|
||||
"""
|
||||
with patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||
AsyncMock(return_value={"supersedes": [], "superseded_by": []})):
|
||||
yield
|
||||
|
||||
|
||||
def _fake_note(*, user_id: int = 7, **overrides) -> MagicMock:
|
||||
note = MagicMock()
|
||||
base = {"id": 1, "title": "t", "body": "b", "tags": [], "is_task": False}
|
||||
@@ -123,6 +138,30 @@ async def test_get_note_returns_dict():
|
||||
assert out["title"] == "found"
|
||||
# Own record: no provenance noise.
|
||||
assert "shared" not in out
|
||||
# No supersession relations: both keys ABSENT, not present-and-empty. A
|
||||
# field that always says nothing trains readers to skip fields (#2483).
|
||||
assert "supersedes" not in out
|
||||
assert "superseded_by" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note_warns_in_words_when_a_later_note_overtook_it():
|
||||
"""The label is the point, not the ids.
|
||||
|
||||
A superseded record still surfaces — supersession demotes, it never hides —
|
||||
so an agent WILL read stale material. Handing it over with only a numeric
|
||||
field to notice would be worse than not surfacing it, because the reader
|
||||
acts on it confidently either way.
|
||||
"""
|
||||
fake = _fake_note(id=5, title="June's answer")
|
||||
with patch("scribe.mcp.tools.notes.notes_svc.get_note_for_user",
|
||||
AsyncMock(return_value=(fake, "owner"))), \
|
||||
patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||
AsyncMock(return_value={"supersedes": [], "superseded_by": [9]})):
|
||||
out = await get_note(note_id=5)
|
||||
assert out["superseded_by"] == [9]
|
||||
assert "superseded_note" in out
|
||||
assert "before acting" in out["superseded_note"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -17,6 +17,18 @@ def _bind_user():
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_systems():
|
||||
"""enter_project now surfaces the project's Systems as the tagging
|
||||
vocabulary (#2546). These are tool-layer unit tests with no database, so
|
||||
the lookup is stubbed to the common case — a project with none. The
|
||||
populated shape is asserted in its own test below.
|
||||
"""
|
||||
with patch("scribe.mcp.tools.projects.systems_svc.list_systems",
|
||||
AsyncMock(return_value=[])):
|
||||
yield
|
||||
|
||||
|
||||
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||
p = MagicMock()
|
||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||
@@ -184,6 +196,48 @@ async def test_enter_project_composes_full_context():
|
||||
# absent. A caller that has to distinguish "no key" from "no system" will
|
||||
# eventually get it wrong.
|
||||
assert out["design_system"] is None
|
||||
# No Systems -> present-and-empty, NOT absent: this key is the tagging
|
||||
# vocabulary, and "this project has no named areas yet" is information the
|
||||
# create-the-System instruction acts on.
|
||||
assert out["systems"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_surfaces_the_systems_vocabulary():
|
||||
"""The tagging instruction is only executable if the vocabulary is in
|
||||
front of the agent when it writes. It never was, and tagging stopped three
|
||||
days after the feature landed — one System, nothing tagged since July 28
|
||||
(#2546's audit). Trimmed to id/name/first-line: it rides on every session
|
||||
start, and the full charter is get_system's job."""
|
||||
p = _fake_project(id=5)
|
||||
sys1 = MagicMock()
|
||||
sys1.id = 3
|
||||
sys1.name = "retrieval"
|
||||
sys1.description = "Embeddings, ranking, auto-inject.\nLong detail below."
|
||||
|
||||
with patch(
|
||||
"scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=p),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.notes_svc.list_notes",
|
||||
AsyncMock(side_effect=[([], 0), ([], 0)]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.systems_svc.list_systems",
|
||||
AsyncMock(return_value=[sys1]),
|
||||
):
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["systems"] == [
|
||||
{"id": 3, "name": "retrieval",
|
||||
"description": "Embeddings, ranking, auto-inject."}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,6 +9,22 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_supersession():
|
||||
"""The auto-inject menu now asks which of its lines are superseded (#278).
|
||||
|
||||
That is a real database call on a path these tests exercise without one.
|
||||
Stubbed to "nothing superseded" — the ordinary state — rather than hidden
|
||||
behind a try/except in the product, which would make the code lie about
|
||||
what it does. The label's own behaviour is covered in
|
||||
tests/test_supersession_ranking.py.
|
||||
"""
|
||||
with patch("scribe.services.plugin_context.superseded_ids",
|
||||
AsyncMock(return_value=set())):
|
||||
yield
|
||||
|
||||
|
||||
from scribe.services import note_usage
|
||||
from scribe.services.note_usage import (
|
||||
empty_usage,
|
||||
|
||||
@@ -17,6 +17,22 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_supersession():
|
||||
"""The auto-inject menu now asks which of its lines are superseded (#278).
|
||||
|
||||
That is a real database call on a path these tests exercise without one.
|
||||
Stubbed to "nothing superseded" — the ordinary state — rather than hidden
|
||||
behind a try/except in the product, which would make the code lie about
|
||||
what it does. The label's own behaviour is covered in
|
||||
tests/test_supersession_ranking.py.
|
||||
"""
|
||||
with patch("scribe.services.plugin_context.superseded_ids",
|
||||
AsyncMock(return_value=set())):
|
||||
yield
|
||||
|
||||
|
||||
|
||||
def _note(id=1, user_id=7, title="A note", note_type="note",
|
||||
is_task=False, task_kind="work"):
|
||||
n = MagicMock()
|
||||
|
||||
@@ -13,8 +13,11 @@ import pytest
|
||||
from scribe.services import backup
|
||||
|
||||
|
||||
def test_backup_version_is_v5():
|
||||
assert backup.BACKUP_VERSION == 5
|
||||
def test_backup_version_is_v6():
|
||||
"""v6 added note_supersessions (#278). The bump is the point of the test —
|
||||
a payload section added without moving the version produces backups that
|
||||
are structurally different and indistinguishable by inspection."""
|
||||
assert backup.BACKUP_VERSION == 6
|
||||
|
||||
|
||||
def test_not_included_lists_the_known_gaps():
|
||||
@@ -102,11 +105,26 @@ async def test_export_full_backup_contains_every_declared_section():
|
||||
assert out["version"] == backup.BACKUP_VERSION
|
||||
assert out["scope"] == "full"
|
||||
assert "api_keys" in out["_not_included"]
|
||||
# The sections v2 silently dropped, plus the six v5 added (empty here).
|
||||
# The sections v2 silently dropped, the six v5 added, and v6's
|
||||
# note_supersessions (all empty here).
|
||||
for key in ("rulebooks", "rulebook_topics", "rules",
|
||||
"rulebook_subscriptions", "rule_suppressions",
|
||||
"topic_suppressions",
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings"):
|
||||
"design_tokens", "note_usage_events", "repo_bindings",
|
||||
"note_supersessions"):
|
||||
assert key in out, f"missing export section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
|
||||
def test_supersession_rows_serialise_the_pair():
|
||||
"""The row builder is a plain function precisely so it can be tested with
|
||||
no database — same reason as the other v5/v6 builders."""
|
||||
class _Row:
|
||||
def __init__(self, a, b):
|
||||
self.superseder_id, self.superseded_id = a, b
|
||||
|
||||
assert backup._note_supersession_rows([_Row(9, 4), _Row(9, 5)]) == [
|
||||
{"superseder_id": 9, "superseded_id": 4},
|
||||
{"superseder_id": 9, "superseded_id": 5},
|
||||
]
|
||||
|
||||
@@ -3,6 +3,22 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_supersession():
|
||||
"""The auto-inject menu now asks which of its lines are superseded (#278).
|
||||
|
||||
That is a real database call on a path these tests exercise without one.
|
||||
Stubbed to "nothing superseded" — the ordinary state — rather than hidden
|
||||
behind a try/except in the product, which would make the code lie about
|
||||
what it does. The label's own behaviour is covered in
|
||||
tests/test_supersession_ranking.py.
|
||||
"""
|
||||
with patch("scribe.services.plugin_context.superseded_ids",
|
||||
AsyncMock(return_value=set())):
|
||||
yield
|
||||
|
||||
|
||||
|
||||
def _rule(rid, title, topic_id):
|
||||
r = MagicMock()
|
||||
r.id, r.title, r.topic_id = rid, title, topic_id
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The supersession claim — who may make it, and what it refuses.
|
||||
|
||||
Step 2 of #278. Ranking behaviour is step 3; this covers only recording and
|
||||
reading the relation.
|
||||
|
||||
The cycle tests are the ones worth reading. Under FLAT demotion a ring of
|
||||
records that supersede each other claims every member is obsolete, so all of
|
||||
them get demoted equally and the whole set drops out of ranked retrieval
|
||||
together — with nothing in the data saying why.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services import supersession
|
||||
|
||||
|
||||
def _session(scalars_sequence=None, get_returns=None):
|
||||
"""A mocked async_session whose execute() yields successive scalar lists."""
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
results = []
|
||||
for scalars in scalars_sequence or []:
|
||||
r = MagicMock()
|
||||
r.scalars.return_value.all.return_value = scalars
|
||||
results.append(r)
|
||||
s.execute = AsyncMock(side_effect=results or None)
|
||||
s.get = AsyncMock(side_effect=get_returns) if get_returns else AsyncMock()
|
||||
s.commit = AsyncMock()
|
||||
s.add = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
def _live_note(note_id=1):
|
||||
n = MagicMock()
|
||||
n.id, n.deleted_at = note_id, None
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_caller_who_cannot_write_the_note_gets_none():
|
||||
with patch("scribe.services.supersession.access.can_write_note",
|
||||
AsyncMock(return_value=False)):
|
||||
assert await supersession.set_supersedes(7, 1, [2]) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_superseding_a_note_you_can_only_READ_is_refused_not_dropped():
|
||||
"""The one case that raises rather than silently skipping.
|
||||
|
||||
Demoting someone else's record out of their retrieval is damage that is
|
||||
invisible from the outside — the caller would believe it worked, and the
|
||||
owner would have no symptom to trace. Rule #47.
|
||||
"""
|
||||
# writable for the superseder (id 1), not for the target (id 2)
|
||||
writable = AsyncMock(side_effect=lambda uid, nid: nid == 1)
|
||||
session = _session(get_returns=[_live_note(2)])
|
||||
with patch("scribe.services.supersession.access.can_write_note", writable), \
|
||||
patch("scribe.services.supersession.async_session", return_value=session):
|
||||
with pytest.raises(PermissionError, match="not yours to supersede"):
|
||||
await supersession.set_supersedes(7, 1, [2])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_supersession_is_dropped_silently():
|
||||
"""Meaningless rather than dangerous, and the DB CHECK refuses it anyway —
|
||||
so it is a drop, not an error the caller has to handle."""
|
||||
session = _session(scalars_sequence=[[]])
|
||||
with patch("scribe.services.supersession.access.can_write_note",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession.set_supersedes(7, 5, [5]) == []
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_trashed_target_is_dropped():
|
||||
"""A claim needs a subject. `get` returns the row, `deleted_at` says it is
|
||||
in the trash, so there is nothing to demote."""
|
||||
trashed = MagicMock()
|
||||
trashed.deleted_at = "2026-08-08"
|
||||
session = _session(scalars_sequence=[[]], get_returns=[trashed])
|
||||
with patch("scribe.services.supersession.access.can_write_note",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession.set_supersedes(7, 1, [2]) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_direct_cycle_is_refused():
|
||||
"""B already supersedes A; A may not now supersede B.
|
||||
|
||||
Walk from the proposed target (B) and see whether it reaches the proposer
|
||||
(A). It does — B -> A — so the edge would close a ring.
|
||||
"""
|
||||
session = _session(scalars_sequence=[[1]]) # B supersedes A(=1)
|
||||
with patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession._closes_a_cycle(session, 1, 2) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_indirect_cycle_is_refused():
|
||||
"""A -> B -> C exists; C may not supersede A.
|
||||
|
||||
Walking from A follows A -> B, then B -> C... and the walk must reach the
|
||||
proposer. Here the proposer is C and the target is A, so: A -> B -> C.
|
||||
"""
|
||||
session = _session(scalars_sequence=[[2], [3]]) # A->B, B->C
|
||||
with patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession._closes_a_cycle(session, 3, 1) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_chain_that_does_not_loop_is_allowed():
|
||||
"""A -> B exists; C may supersede A. Walking from A reaches only B."""
|
||||
session = _session(scalars_sequence=[[2], []])
|
||||
with patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession._closes_a_cycle(session, 3, 1) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_cycle_walk_terminates_on_an_existing_ring():
|
||||
"""Defensive: if a ring somehow exists (written directly to the DB), the
|
||||
walk must not spin. The visited set is what guarantees it, and this pins
|
||||
that guarantee rather than trusting it."""
|
||||
# 1 -> 2, 2 -> 1: a ring that does not contain the proposer (99).
|
||||
session = _session(scalars_sequence=[[2], [1], []])
|
||||
with patch("scribe.services.supersession.async_session", return_value=session):
|
||||
assert await supersession._closes_a_cycle(session, 99, 1) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_superseded_ids_is_empty_for_an_empty_candidate_set():
|
||||
"""Ranking calls this per query. An empty candidate set must not become a
|
||||
`WHERE id IN ()`, which Postgres accepts and every reader misreads."""
|
||||
assert await supersession.superseded_ids([]) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_are_empty_when_the_caller_cannot_read_the_note():
|
||||
with patch("scribe.services.supersession.access.can_read_note",
|
||||
AsyncMock(return_value=False)):
|
||||
assert await supersession.get_relations(7, 1) == {
|
||||
"supersedes": [], "superseded_by": []
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_relations_partitions_both_directions_from_one_query():
|
||||
"""ONE round trip for both directions, because this runs on every note read.
|
||||
|
||||
Note 5 supersedes 2 and 3, and is itself superseded by 9. All four rows come
|
||||
back from a single OR query and are partitioned by which column holds 5.
|
||||
"""
|
||||
session = AsyncMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
result = MagicMock()
|
||||
result.all.return_value = [(5, 3), (5, 2), (9, 5)] # (superseder, superseded)
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
|
||||
with patch("scribe.services.supersession.access.can_read_note",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.supersession.async_session", return_value=session):
|
||||
rel = await supersession.get_relations(7, 5)
|
||||
|
||||
assert rel == {"supersedes": [2, 3], "superseded_by": [9]}
|
||||
assert session.execute.await_count == 1, (
|
||||
"both directions must come from one query — asking separately doubles "
|
||||
"the round trips on the hottest path in the product"
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Demotion: superseded records rank behind their equals, and are never hidden.
|
||||
|
||||
Step 3 of #278. The distinction these tests exist to protect is DEMOTE vs
|
||||
FILTER. The operator was explicit:
|
||||
|
||||
"the failure is pollution, not existence"
|
||||
|
||||
Hiding a superseded record would turn every one of them into something you must
|
||||
already know exists in order to find, and would destroy "what did we think
|
||||
then" — half the reason a log is kept. So every test here that could be
|
||||
satisfied by dropping a record instead checks that it is still present.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services.embeddings import (
|
||||
_SUPERSESSION_PENALTY,
|
||||
_apply_supersession_penalty,
|
||||
)
|
||||
|
||||
|
||||
def _note(note_id: int):
|
||||
n = MagicMock()
|
||||
n.id = note_id
|
||||
return n
|
||||
|
||||
|
||||
def _stale(*ids):
|
||||
return patch(
|
||||
"scribe.services.supersession.superseded_ids",
|
||||
AsyncMock(return_value=set(ids)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_superseded_record_falls_behind_an_equal_live_one():
|
||||
scored = [(0.70, _note(1)), (0.69, _note(2))] # 1 leads on raw score
|
||||
with _stale(1):
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [2, 1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_strong_superseded_record_still_beats_a_weak_live_one():
|
||||
"""Demote, not filter — and this is why the penalty is small.
|
||||
|
||||
Supersession is a claim about SOME of a record's content. One that strongly
|
||||
answers a question nothing else answers should still surface, just behind
|
||||
anything comparable that is current.
|
||||
"""
|
||||
scored = [(0.90, _note(1)), (0.50, _note(2))]
|
||||
with _stale(1):
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [1, 2]
|
||||
assert out[0][0] == pytest.approx(0.90 - _SUPERSESSION_PENALTY)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_superseded_record_is_still_returned():
|
||||
"""The whole point. A test that only checked ordering would pass just as
|
||||
happily against an implementation that dropped it."""
|
||||
scored = [(0.70, _note(1))]
|
||||
with _stale(1):
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_returned_score_is_the_adjusted_one():
|
||||
"""Downstream gates must see the adjusted value — the auto-inject margin
|
||||
band in particular, which exists to stop near-ties dragging in neighbours
|
||||
and would otherwise re-tie exactly what this just separated."""
|
||||
scored = [(0.70, _note(1)), (0.68, _note(2))]
|
||||
with _stale(1):
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
by_id = {int(n.id): s for s, n in out}
|
||||
assert by_id[1] == pytest.approx(0.65)
|
||||
assert by_id[2] == pytest.approx(0.68)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_superseded_leaves_the_order_untouched():
|
||||
scored = [(0.70, _note(1)), (0.69, _note(2)), (0.60, _note(3))]
|
||||
with _stale():
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ties_keep_their_database_order():
|
||||
"""Stable sort. Equal scores must not reshuffle per call — a menu that
|
||||
reorders between identical queries reads as nondeterminism and sends
|
||||
someone hunting for a bug that isn't there."""
|
||||
scored = [(0.70, _note(1)), (0.70, _note(2)), (0.70, _note(3))]
|
||||
with _stale():
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_limit_is_applied_after_reordering():
|
||||
"""Over-fetching is pointless if the cut happens first. Three candidates,
|
||||
limit 2, and the demoted leader must be the one that falls out."""
|
||||
scored = [(0.70, _note(1)), (0.69, _note(2)), (0.68, _note(3))]
|
||||
with _stale(1):
|
||||
out = await _apply_supersession_penalty(scored, limit=2)
|
||||
assert [int(n.id) for _s, n in out] == [2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_lookup_returns_unpenalised_results_not_none():
|
||||
"""Fail OPEN, and the direction matters. Ranking without the penalty is the
|
||||
behaviour that shipped for months; returning nothing would turn a
|
||||
supersession hiccup into a broken search."""
|
||||
scored = [(0.70, _note(1)), (0.69, _note(2))]
|
||||
with patch("scribe.services.supersession.superseded_ids",
|
||||
AsyncMock(side_effect=RuntimeError("db gone"))):
|
||||
out = await _apply_supersession_penalty(scored, limit=5)
|
||||
assert [int(n.id) for _s, n in out] == [1, 2]
|
||||
assert out[0][0] == pytest.approx(0.70)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_candidate_set_short_circuits():
|
||||
"""No candidates means no lookup — this runs on every ranked query, and a
|
||||
round trip to learn nothing is a round trip too many."""
|
||||
called = AsyncMock(return_value=set())
|
||||
with patch("scribe.services.supersession.superseded_ids", called):
|
||||
assert await _apply_supersession_penalty([], limit=5) == []
|
||||
called.assert_not_awaited()
|
||||
|
||||
|
||||
def test_the_penalty_is_sized_to_reorder_a_cluster_not_shuffle_within_it():
|
||||
"""Sized against a measurement, not by feel.
|
||||
|
||||
Measured 2026-08-07 (#2486): the notes competing on a dev-log's own title
|
||||
phrase sat within ~0.014 of each other, spanning 0.6506 down to 0.6120. A
|
||||
penalty smaller than that spread would move a record within a tie without
|
||||
changing which one wins — the failure #2486 already proved cannot be tuned
|
||||
away, because the neighbours are not barely passing, they are tied.
|
||||
|
||||
The upper bound is the operator's constraint, not an optimisation: a penalty
|
||||
large enough to bury a superseded record outright is hiding by another name.
|
||||
"""
|
||||
assert _SUPERSESSION_PENALTY > 0.014, (
|
||||
"must exceed the measured neighbour spread, or it reorders nothing"
|
||||
)
|
||||
assert _SUPERSESSION_PENALTY < 0.15, (
|
||||
"must not bury a superseded record outright — that is hiding, which "
|
||||
"the operator ruled out"
|
||||
)
|
||||
Reference in New Issue
Block a user