Everything is verified green. Final report: **Phase 106 — Document dates (final verification pass; all 10 tasks already complete)** - Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed. - `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓) - `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up) - 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation** - `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** **Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions. - **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring). - **Next pending phase:** none — `todo/` holds only this phase.
176 lines
7.5 KiB
Python
176 lines
7.5 KiB
Python
"""Unit: the D6 recency boost on the fused score (phase 106, task 07).
|
||
|
||
Pure-function pins for
|
||
:func:`app.rag.retriever.apply_recency_boost` — the decay magnitude
|
||
(zero age → full weight, one half-life → ``weight/e``, ten half-lives
|
||
→ negligible), the future-date clamp, the ``weight=0`` kill switch
|
||
(byte-identical scores AND order), the tie-breaks (a raw-score tie
|
||
breaks toward the newer document; the ``(path, position)`` key still
|
||
applies when scores AND cosines AND ages are equal), and the
|
||
no-mutation contract (the ``fuse`` convention). Fake rows, no DB —
|
||
the real-Postgres fine-line battery (the owner's scenario) lives in
|
||
``tests/integration/test_recency_boost.py``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pytest
|
||
|
||
from app.config import get_settings
|
||
from app.models import Document
|
||
from app.rag.retriever import RetrievedChunk, apply_recency_boost
|
||
|
||
#: A fixed "now" — the pins must not depend on the wall clock.
|
||
NOW = datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
|
||
|
||
|
||
def _doc(path: str, created_at: datetime, source: str = "Homelab") -> Document:
|
||
return Document(
|
||
id=uuid.uuid4(),
|
||
source=source,
|
||
path=path,
|
||
full_path=f"/tmp/{path}",
|
||
title=path,
|
||
content="x",
|
||
content_hash="0" * 64,
|
||
indexed_at=created_at,
|
||
created_at=created_at,
|
||
)
|
||
|
||
|
||
def _chunk(
|
||
doc: Document, score: float, position: int = 0, cosine: float = 0.9
|
||
) -> RetrievedChunk:
|
||
return RetrievedChunk(
|
||
chunk_id=uuid.uuid4(),
|
||
position=position,
|
||
content="x",
|
||
score=score,
|
||
document=doc,
|
||
cosine=cosine,
|
||
)
|
||
|
||
|
||
def test_zero_age_gets_the_full_weight_exactly() -> None:
|
||
"""Age 0 → ``+weight`` with no float drift (``exp(0) == 1.0``)."""
|
||
c = _chunk(_doc("a.md", NOW), 0.032787)
|
||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||
assert out[0].score == 0.032787 + 0.0007
|
||
|
||
|
||
def test_one_half_life_of_age_decays_to_weight_over_e() -> None:
|
||
"""Age = half-life → ``+weight·e⁻¹`` (±1e-9)."""
|
||
c = _chunk(_doc("a.md", NOW - timedelta(days=365)), 0.032787)
|
||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||
assert out[0].score == pytest.approx(
|
||
0.032787 + 0.0007 * math.exp(-1.0), abs=1e-9
|
||
)
|
||
|
||
|
||
def test_ten_half_lives_of_age_is_negligible() -> None:
|
||
"""Age 10× the half-life → ``+weight·e⁻¹⁰`` < ``weight·1e-3`` — the
|
||
boost has faded to nothing (recency is an age signal, not a binary)."""
|
||
c = _chunk(_doc("a.md", NOW - timedelta(days=3650)), 0.032787)
|
||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||
assert out[0].score - 0.032787 < 0.0007 * 1e-3
|
||
|
||
|
||
def test_future_created_at_clamps_to_zero_age() -> None:
|
||
"""A future ``created_at`` clamps to age 0 — the full weight (the D3
|
||
today-folding consistency: a future-sourced doc reads as brand-new)."""
|
||
c = _chunk(_doc("a.md", NOW + timedelta(days=30)), 0.032787)
|
||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||
assert out[0].score == 0.032787 + 0.0007
|
||
|
||
|
||
def test_weight_zero_is_byte_identical_scores_and_order() -> None:
|
||
"""The kill switch: ``weight=0`` leaves every score untouched and the
|
||
order byte-identical for an already-fused (already 4-key-sorted)
|
||
input."""
|
||
a0 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.03, cosine=0.9, position=0)
|
||
b0 = _chunk(_doc("b.md", NOW - timedelta(days=20)), 0.03, cosine=0.8, position=0)
|
||
a1 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.02, cosine=0.95, position=0)
|
||
c1 = _chunk(_doc("c.md", NOW - timedelta(days=30)), 0.02, cosine=0.5, position=1)
|
||
chunks = [a0, b0, a1, c1] # already sorted by the 4-key order
|
||
out = apply_recency_boost(chunks, now=NOW, weight=0.0, half_life_days=365)
|
||
assert [
|
||
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in out
|
||
] == [
|
||
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in chunks
|
||
]
|
||
|
||
|
||
def test_raw_score_tie_breaks_toward_the_newer_document() -> None:
|
||
"""An EXACT raw-score + cosine tie: the boost moves only the newer
|
||
document (the older one's boost has decayed to ~0), so the newer
|
||
rank rises above it — and with ``weight=0`` the pre-phase
|
||
(path-ordered) ranking stands."""
|
||
old = _chunk(_doc("a-old.md", NOW - timedelta(days=2500)), 0.016129)
|
||
new = _chunk(_doc("b-new.md", NOW), 0.016129)
|
||
out = apply_recency_boost([old, new], now=NOW, weight=0.0007, half_life_days=365)
|
||
assert [rc.document.path for rc in out] == ["b-new.md", "a-old.md"]
|
||
out_off = apply_recency_boost([old, new], now=NOW, weight=0.0, half_life_days=365)
|
||
assert [rc.document.path for rc in out_off] == ["a-old.md", "b-new.md"]
|
||
|
||
|
||
def test_path_position_tiebreak_when_scores_cosines_and_ages_equal() -> None:
|
||
"""When the boosted scores AND cosines are equal (same age → same
|
||
boost), the EXISTING ``(path, position)`` tie-break still decides —
|
||
first by path, then by position within one path."""
|
||
a = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
|
||
b = _chunk(_doc("b.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
|
||
out = apply_recency_boost([b, a], now=NOW, weight=0.001, half_life_days=365)
|
||
assert [rc.document.path for rc in out] == ["a.md", "b.md"]
|
||
# Same path, different positions (same doc, same age, same score):
|
||
p1 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
|
||
p0 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
|
||
out2 = apply_recency_boost([p1, p0], now=NOW, weight=0.001, half_life_days=365)
|
||
assert [rc.position for rc in out2] == [0, 1]
|
||
|
||
|
||
def test_defaults_come_from_settings_when_omitted() -> None:
|
||
"""Omitted *weight* / *half_life_days* fall back to the settings
|
||
(``recency_boost`` / ``recency_half_life_days``) — the explicit
|
||
settings values must reproduce the default call exactly."""
|
||
chunks = [
|
||
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
|
||
_chunk(_doc("b.md", NOW - timedelta(days=700)), 0.02),
|
||
]
|
||
s = get_settings()
|
||
out_default = apply_recency_boost(chunks, now=NOW)
|
||
out_explicit = apply_recency_boost(
|
||
chunks,
|
||
now=NOW,
|
||
weight=s.recency_boost,
|
||
half_life_days=s.recency_half_life_days,
|
||
)
|
||
assert [rc.score for rc in out_default] == [rc.score for rc in out_explicit]
|
||
|
||
|
||
def test_inputs_are_never_mutated() -> None:
|
||
"""The ``fuse`` convention: the input list and its scores are
|
||
untouched — every returned chunk is a fresh ``replace()`` copy."""
|
||
chunks = [
|
||
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
|
||
_chunk(_doc("b.md", NOW), 0.03),
|
||
]
|
||
original_scores = [rc.score for rc in chunks]
|
||
original_order = [rc.chunk_id for rc in chunks]
|
||
out = apply_recency_boost(chunks, now=NOW, weight=0.001, half_life_days=365)
|
||
assert [rc.score for rc in chunks] == original_scores
|
||
assert [rc.chunk_id for rc in chunks] == original_order
|
||
assert out is not chunks
|
||
assert all(o is not i for o, i in zip(out, chunks, strict=True))
|
||
|
||
|
||
def test_non_positive_half_life_fails_loud() -> None:
|
||
"""A ``half_life_days <= 0`` argument would divide the exponent by
|
||
zero — the settings validator guards startup, the function guards
|
||
direct calls (the ``fuse`` ``k <= 0`` pattern)."""
|
||
c = _chunk(_doc("a.md", NOW), 0.02)
|
||
with pytest.raises(ValueError, match="half_life_days must be > 0"):
|
||
apply_recency_boost([c], now=NOW, weight=0.001, half_life_days=0)
|