Skip to content

Commit 551f17d

Browse files
authored
Merge pull request #23 from nickroci/feat/usefulness-tiebreaker
feat(daemon): usefulness tiebreaker in priming rank
2 parents 3e0f678 + 0336f8a commit 551f17d

8 files changed

Lines changed: 256 additions & 21 deletions

File tree

README.md

Lines changed: 7 additions & 7 deletions
Large diffs are not rendered by default.

daemon/agent_mem_daemon/decay.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,25 @@ def _last_activity_date(fm: Dict[str, Any]) -> Optional[date]:
318318
return max(valid)
319319

320320

321+
# TODO(decay): couple decay to USEFUL surfacing, not bare surfacing.
322+
# `_last_activity_date` counts any `last_surfaced` as activity, and
323+
# `record_surface` stamps it on EVERY surface — so an entry surfaced often but
324+
# never relied on (high `fired`, low `fired-helpful` — the "ignored" /
325+
# prefrontal-inhibition case) resists decay exactly as much as a genuinely
326+
# useful one. That's backwards: those are the entries we most want to fade.
327+
# Two directions to explore:
328+
# 1. Reset/extend the half-life on a HELPFUL surface (`fired-helpful` bump)
329+
# rather than on every surface — reset on real *use*, not mere exposure.
330+
# Closes the hole above and implements the design's "surfaced but not
331+
# relevant -> decay" leg.
332+
# 2. Tier the half-life off our real counters instead of one DECAY_AGE_DAYS
333+
# + a binary reinforced-floor cliff: more-proven entries (reinforced /
334+
# fired-helpful) earn a smoothly longer life, rather than the current
335+
# immune/not cliff.
336+
# When a merge path lands, SUM the reinforced/fired/fired-helpful counters
337+
# across sources rather than taking the max — max() silently destroys
338+
# accumulated evidence. See knowledge/projects/agent-mem/concepts/
339+
# forgetting-ltd-decay-design.
321340
def _is_eligible_for_archive(
322341
fm: Dict[str, Any],
323342
*,

daemon/agent_mem_daemon/priming.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,11 @@ def _shorten(text: str, *, max_chars: int = 80) -> str:
129129
return text[: max_chars - 1].rstrip() + "…"
130130

131131

132-
def _reinforced_count(fm: Dict[str, Any]) -> int:
133-
raw = fm.get("reinforced")
132+
def _nonneg_int_field(fm: Dict[str, Any], key: str) -> int:
133+
"""A frontmatter integer counter as a non-negative int (0 when absent
134+
or unparseable). Shared by the reinforced / fired / fired-helpful
135+
readers so the coercion lives in one place."""
136+
raw = fm.get(key)
134137
if raw is None:
135138
return 0
136139
try:
@@ -140,6 +143,57 @@ def _reinforced_count(fm: Dict[str, Any]) -> int:
140143
return n if n > 0 else 0
141144

142145

146+
def _reinforced_count(fm: Dict[str, Any]) -> int:
147+
return _nonneg_int_field(fm, "reinforced")
148+
149+
150+
# ── Usefulness tiebreaker (fired-helpful / fired) ─────────────────────
151+
#
152+
# ``fired`` counts how often an entry was surfaced in priming; ``fired-
153+
# helpful`` counts how often a surfaced entry was actually relied on (the
154+
# Librarian's used_helpfully signal). Their ratio is a quality prior: "when
155+
# this entry shows up, how often does it earn its place?" We fold it into
156+
# ranking as a SMALL additive nudge — a tiebreaker among candidates the
157+
# cross-encoder reranker already considers comparable, never a primary
158+
# ranking term. Keeping it gentle is deliberate: the design's own warning
159+
# is that ranking by usefulness creates a self-reinforcing surfacing loop
160+
# (surface -> accrue helpful -> rank higher -> surface more), so usefulness
161+
# must not be able to override a real applicability difference.
162+
# See knowledge/projects/agent-mem/concepts/forgetting-ltd-decay-design.
163+
#
164+
# The estimate is Beta-smoothed and centered on the prior mean: a raw ratio
165+
# is unusable at low counts (1/1 = "100% useful" beats 5/20), and centering
166+
# makes a zero-data entry contribute EXACTLY 0 — new entries are judged on
167+
# the reranker alone (clean cold-start), proven entries float up a hair,
168+
# chronically-surfaced-but-ignored entries sink a hair.
169+
_USEFULNESS_PRIOR_ALPHA = 1.0 # prior "helpful" pseudo-count
170+
_USEFULNESS_PRIOR_BETA = 4.0 # prior "ignored" pseudo-count -> prior mean 0.2
171+
# Provisional and uncalibrated: fired-helpful is ~0 across the library until
172+
# the capture (shipped 2026-06) accrues weeks of data, so this weight cannot
173+
# be tuned against the real distribution yet. Set small so the centered score
174+
# (range ~[-0.2, +0.8]) swings the boosted score by at most ~+0.08 / -0.02
175+
# logits — bigger than the scope bonus (0.02), far smaller than one
176+
# reinforcement (0.5) or a meaningful rerank gap.
177+
_USEFULNESS_TIEBREAK_WEIGHT = 0.1
178+
179+
180+
def _usefulness_score(fm: Dict[str, Any]) -> float:
181+
"""Beta-smoothed, prior-centered usefulness in roughly [-0.2, +0.8].
182+
183+
Returns 0.0 when the entry has never been surfaced (``fired`` absent or
184+
0) so untracked entries are neutral. Above the prior mean -> positive
185+
(rank nudge up); below -> negative (nudge down)."""
186+
fired = _nonneg_int_field(fm, "fired")
187+
if fired <= 0:
188+
return 0.0
189+
helpful = _nonneg_int_field(fm, "fired-helpful")
190+
a = _USEFULNESS_PRIOR_ALPHA
191+
b = _USEFULNESS_PRIOR_BETA
192+
smoothed = (helpful + a) / (fired + a + b)
193+
prior_mean = a / (a + b)
194+
return smoothed - prior_mean
195+
196+
143197
def _entry_title(fm: Dict[str, Any], path: Path) -> str:
144198
"""Punchy human-readable title for the entry. Falls back to the
145199
slug (de-kebabed) if no ``title:`` frontmatter is present."""
@@ -580,7 +634,12 @@ def _boost_with_reinforcement(
580634
knowledge_dir: Optional[Path] = None,
581635
current_project_slug: Optional[str] = None,
582636
) -> List[Tuple[Path, float, int]]:
583-
"""Re-rank by ``score + reinforced * 0.5 + scope_bonus``.
637+
"""Re-rank by ``score + reinforced*0.5 + scope_bonus + usefulness_tiebreak``.
638+
639+
The usefulness term is a small, prior-centered nudge from the
640+
fired-helpful/fired ratio (see ``_usefulness_score``) — a tiebreaker
641+
among comparable candidates, never a primary ranking signal. Untracked
642+
entries contribute exactly 0.
584643
585644
The reinforcement multiplier is gentle on purpose: reinforced is an
586645
integer counter the user has driven up by repetition, and BM25 scores
@@ -622,7 +681,38 @@ def _boost_with_reinforcement(
622681
current_project_slug,
623682
aliases,
624683
)
625-
boosted = score + reinforced * 0.5 + scope
684+
usefulness = _usefulness_score(fm)
685+
# TODO(usefulness): this is a deliberately gentle TIEBREAKER. Grow it
686+
# into a real signal once fired-helpful has weeks of data to calibrate
687+
# against (it is ~0 library-wide until the 2026-06 capture accrues).
688+
# Ideas, roughly in order of value:
689+
# 1. Evidence-gate the weight: scale by fired/(fired+K) so low-count
690+
# entries barely move. Smoothing fixes the point estimate; gating
691+
# fixes the weight magnitude — distinct jobs, do both.
692+
# 2. Asymmetric "ignored" penalty (the prefrontal-inhibition analog):
693+
# high fired + low fired-helpful is strong evidence an entry is
694+
# noise in the contexts where it keeps surfacing. Don't inflate the
695+
# RANKING penalty (that feeds the self-reinforcing surfacing loop) —
696+
# route the negative leg into DECAY (decay.py sweep) so ignored
697+
# entries lose half-life. That's where the design says it belongs
698+
# and where the loop self-damps. We already have the sweep; it just
699+
# doesn't read fired-helpful yet.
700+
# 3. RIF / near-duplicate negative ticks: when the top entry fires-
701+
# and-helps, give near-competitors that also fired a small negative
702+
# tick so monotonic-up counters don't entrench duplicates.
703+
# 4. Put all four boost terms on a COMMON SCALE. Rerank logits are
704+
# ~[-3, +10], scope is ±0.02, reinforced is 0.5/unit, usefulness is
705+
# ~[-0.02, +0.08]: incommensurable today (see the NOTE on _SCOPE_*).
706+
# Normalise (e.g. squash the rerank logit to a bounded range) so
707+
# each weight means something comparable, then re-tune all three.
708+
# 5. Telemetry: surface usefulness alongside the boosted score in the
709+
# run record so the weight can be calibrated from real data instead
710+
# of guessed.
711+
# Design rationale: knowledge/projects/agent-mem/concepts/forgetting-
712+
# ltd-decay-design (decay-with-reinforcement, encoding-strength-as-
713+
# rank-modifier, RIF, archive-not-delete). The usefulness-into-decay
714+
# direction (idea 2) is tracked as a TODO in decay.py's sweep.
715+
boosted = score + reinforced * 0.5 + scope + _USEFULNESS_TIEBREAK_WEIGHT * usefulness
626716
enriched.append((path, boosted, reinforced, score))
627717
enriched.sort(key=lambda t: (-t[1], str(t[0])))
628718
return [(p, ranked, reinforced) for p, ranked, reinforced, _orig in enriched]

daemon/agent_mem_daemon/priming_rpc.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -381,19 +381,38 @@ def _handle_fetch_entry(req: RpcRequest) -> RpcResponse:
381381
if raw.startswith("[[") and raw.endswith("]]"):
382382
raw = raw[2:-2].strip()
383383
raw = raw.strip("/")
384-
target_rel = raw if raw.endswith(".md") else raw + ".md"
384+
if not raw:
385+
return {"ok": False, "error": "path must name an entry or folder"}
385386

386387
kdir = knowledge_dir()
387388
if not kdir.exists():
388389
return {"ok": False, "error": "knowledge dir not found"}
389390
kroot = kdir.resolve()
390-
target = (kroot / target_rel).resolve()
391-
try:
392-
target.relative_to(kroot)
393-
except ValueError:
394-
return {"ok": False, "error": "path escapes knowledge dir"}
395-
if not target.is_file():
396-
return {"ok": False, "error": f"entry not found: {target_rel}"}
391+
392+
# Resolve to a concrete .md file. A bare folder path (with or without a
393+
# trailing slash) resolves to that folder's README.md — so `projects/foo`
394+
# and `projects/foo/` browse the folder instead of 404-ing on the
395+
# non-existent `projects/foo.md`. An explicit `.md` suffix is always an
396+
# entry; otherwise try the entry first, then fall back to the folder README.
397+
rels = [raw] if raw.endswith(".md") else [f"{raw}.md", f"{raw}/README.md"]
398+
target: Optional[Path] = None
399+
for rel in rels:
400+
cand = (kroot / rel).resolve()
401+
try:
402+
cand.relative_to(kroot)
403+
except ValueError:
404+
return {"ok": False, "error": "path escapes knowledge dir"}
405+
if cand.is_file():
406+
target = cand
407+
break
408+
if target is None:
409+
return {
410+
"ok": False,
411+
"error": (
412+
f"entry not found: {raw} (tried {', '.join(rels)}). "
413+
"If you meant a topic rather than a path, re-run as a free-text query."
414+
),
415+
}
397416

398417
content = target.read_text(encoding="utf-8")
399418
parent = target.parent

daemon/tests/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def render_entry(
3535
applies_when: str,
3636
keywords: List[str],
3737
reinforced: Optional[int] = None,
38+
fired: int = 0,
39+
fired_helpful: int = 0,
3840
body: str = "",
3941
scope: str = "global",
4042
default_body_template: str = DEFAULT_BODY_TEMPLATE,
@@ -60,8 +62,8 @@ def render_entry(
6062
lines.append(f'title: "{title}"')
6163
lines.append("created: 2026-05-19")
6264
lines.append("updated: 2026-05-19")
63-
lines.append("fired: 0")
64-
lines.append("fired-helpful: 0")
65+
lines.append(f"fired: {fired}")
66+
lines.append(f"fired-helpful: {fired_helpful}")
6567
if reinforced is not None:
6668
lines.append(f"reinforced: {reinforced}")
6769
lines.append("sources:")

daemon/tests/test_priming.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,64 @@ def test_refresh_reinforced_counter_boosts_rank(tmp_path):
225225
assert "(×" not in lines[1]
226226

227227

228+
def _write_useful_pair(k: Path) -> None:
229+
"""Two entries identical except for their fired / fired-helpful counts:
230+
``proven`` was relied on 8/10 surfaces, ``ignored`` 0/10."""
231+
(k / "global").mkdir(parents=True, exist_ok=True)
232+
_write(
233+
k / "global" / "proven.md",
234+
_entry(
235+
id_="proven",
236+
title="Proven entry",
237+
applies_when="working with foo",
238+
keywords=["foo"],
239+
fired=10,
240+
fired_helpful=8,
241+
),
242+
)
243+
_write(
244+
k / "global" / "ignored.md",
245+
_entry(
246+
id_="ignored",
247+
title="Ignored entry",
248+
applies_when="working with foo",
249+
keywords=["foo"],
250+
fired=10,
251+
fired_helpful=0,
252+
),
253+
)
254+
255+
256+
def test_usefulness_breaks_tie_between_equal_rerank_candidates(tmp_path):
257+
"""When two candidates score identically, the one actually relied on
258+
(higher fired-helpful/fired) wins the tiebreak."""
259+
k = tmp_path / "knowledge"
260+
_write_useful_pair(k)
261+
# Identical rerank scores — only the usefulness tiebreaker can order them.
262+
hits = [
263+
(k / "global" / "ignored.md", 3.0),
264+
(k / "global" / "proven.md", 3.0),
265+
]
266+
ranked = priming._boost_with_reinforcement(hits)
267+
order = [p.stem for p, _score, _r in ranked]
268+
assert order == ["proven", "ignored"]
269+
270+
271+
def test_usefulness_does_not_override_a_clear_rerank_gap(tmp_path):
272+
"""The tiebreaker is gentle: a clearly stronger rerank score wins even
273+
when that entry is chronically ignored and the weaker one is maximally
274+
useful. Usefulness can only break near-ties, not flip real relevance."""
275+
k = tmp_path / "knowledge"
276+
_write_useful_pair(k)
277+
# proven (useful) gets the weak rerank score; ignored gets a strong one.
278+
hits = [
279+
(k / "global" / "proven.md", 2.0),
280+
(k / "global" / "ignored.md", 5.0),
281+
]
282+
ranked = priming._boost_with_reinforcement(hits)
283+
assert ranked[0][0].stem == "ignored"
284+
285+
228286
def test_refresh_idempotent(tmp_path):
229287
k = _seed_library(tmp_path)
230288
out = tmp_path / "hot-context.md"

daemon/tests/test_priming_internals.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,31 @@ def test_reinforced_count_passes_through_positive() -> None:
101101
assert priming._reinforced_count({"reinforced": 4}) == 4
102102

103103

104+
# ── _usefulness_score (fired-helpful / fired tiebreaker) ──────────────
105+
106+
107+
def test_usefulness_score_zero_when_never_fired() -> None:
108+
# No track record -> exactly neutral, so cold-start entries rank on the
109+
# reranker alone.
110+
assert priming._usefulness_score({}) == 0.0
111+
assert priming._usefulness_score({"fired": 0, "fired-helpful": 0}) == 0.0
112+
113+
114+
def test_usefulness_score_positive_when_mostly_helpful() -> None:
115+
# 8/10 helpful sits well above the 0.2 prior mean.
116+
assert priming._usefulness_score({"fired": 10, "fired-helpful": 8}) > 0.0
117+
118+
119+
def test_usefulness_score_negative_when_surfaced_but_ignored() -> None:
120+
# 0/20 helpful -> below the prior mean -> a gentle demotion.
121+
assert priming._usefulness_score({"fired": 20, "fired-helpful": 0}) < 0.0
122+
123+
124+
def test_usefulness_score_handles_garbage() -> None:
125+
assert priming._usefulness_score({"fired": "x", "fired-helpful": "y"}) == 0.0
126+
assert priming._usefulness_score({"fired": 5, "fired-helpful": None}) < 0.0
127+
128+
104129
# ── _entry_title fallback ────────────────────────────────────────────
105130

106131

daemon/tests/test_priming_rpc.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,28 @@ def test_fetch_entry_rejects_missing_entry(tmp_path, rpc_server):
498498
assert "not found" in resp["error"]
499499

500500

501+
def test_fetch_entry_resolves_folder_to_readme(tmp_path, rpc_server):
502+
"""A bare folder path (no trailing slash) resolves to that folder's
503+
README — so `global/python` browses the folder instead of 404-ing on
504+
the non-existent `global/python.md`."""
505+
_seed_library(tmp_path)
506+
_thread, socket_path = rpc_server
507+
resp = _send_request(socket_path, {"op": "fetch_entry", "path": "global/python"})
508+
assert resp["ok"] is True
509+
assert resp["path"].endswith("global/python/README.md")
510+
assert "# python" in resp["content"]
511+
# Siblings are the folder's entries (the README itself is excluded).
512+
assert any("use-uv" in s for s in resp["siblings"])
513+
514+
515+
def test_fetch_entry_folder_trailing_slash_also_resolves_readme(tmp_path, rpc_server):
516+
_seed_library(tmp_path)
517+
_thread, socket_path = rpc_server
518+
resp = _send_request(socket_path, {"op": "fetch_entry", "path": "global/python/"})
519+
assert resp["ok"] is True
520+
assert resp["path"].endswith("global/python/README.md")
521+
522+
501523
def test_fetch_entry_rejects_empty_path(rpc_server):
502524
_thread, socket_path = rpc_server
503525
resp = _send_request(socket_path, {"op": "fetch_entry", "path": " "})

0 commit comments

Comments
 (0)