@@ -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+
143197def _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 ]
0 commit comments