phase: 106_document_dates
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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user