Skip to content

feat: sigmoid recency decay as configurable alternative to linear#2292

Open
garyd9 wants to merge 1 commit into
vectorize-io:mainfrom
garyd9:feat/sigmoid-recency-decay
Open

feat: sigmoid recency decay as configurable alternative to linear#2292
garyd9 wants to merge 1 commit into
vectorize-io:mainfrom
garyd9:feat/sigmoid-recency-decay

Conversation

@garyd9

@garyd9 garyd9 commented Jun 18, 2026

Copy link
Copy Markdown

PR: Add configurable sigmoid recency decay as alternative to linear

Summary

Replace the linear recency decay in apply_combined_scoring with an optional sigmoid curve that keeps memories sharp for ~6 weeks, then gradually demotes them — crossing neutral around 79 days and flooring at ~9 months.

Note: This PR is submitted in monkey-patch form (currently deployed via a plugin override file). The author is not deeply familiar with Hindsight's internals and is submitting this for discussion. A maintainer should properly integrate this into the config system (env vars, hierarchical config, etc.) before merging.

Problem

The current linear recency decay in engine/search/reranking.py penalizes all memories uniformly as they age:

sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))

This means a memory from 1 day ago and a memory from 30 days ago are treated very differently, even though both are recent enough to be highly relevant. Meanwhile, a memory from 6 months ago and one from 11 months ago are both deeply penalized, even though the 6-month memory may still be useful.

Real memories don't work like that. They stay sharp for a while, then fade — not linearly, but in an S-curve. A memory from last week is just as vivid as one from yesterday. A memory from a year ago is essentially gone. The transition between "sharp" and "gone" is where the interesting behavior happens.

What the sigmoid does

The sigmoid curve: 0.60 + 0.50 / (1 + exp(-0.03 * (125 - days_ago)))

Age (days) Recency boost Effect
0–42 ~1.10 Full boost — memories stay sharp
42–79 1.10 → 1.00 Gradual decline, crossing "neutral"
79–180 1.00 → 0.70 Active demotion of stale memories
180–270 0.70 → 0.62 Approaching floor
270+ ~0.60 Floor — old memories persist but are always penalized

The key property: recent memories are treated equally (the flat top), and the decay curve matches how human memory actually works — not a straight line, but a sigmoid.

Why this matters

For agent memory systems, the retrieval scoring directly controls what gets injected into the agent's context. With linear decay, a 2-week-old memory that's still highly relevant gets penalized at the same rate as semantic relevance changes. The sigmoid lets recent-but-relevant memories compete fairly with brand-new ones, while still suppressing genuinely stale content.

In practice: operational memories from last month stay useful. Operational memories from 6 months ago fade. The agent doesn't have to fight its own memory system to surface the right context.

Current implementation (monkey-patch)

The patch is applied as a monkey-patch in the Hermes agent's Hindsight plugin override file (~/.hermes/plugins/memory/hindsight/__init__.py). It:

  1. Imports hindsight_api.engine.search.reranking at runtime
  2. Saves a reference to the original apply_combined_scoring
  3. Calls the original first (preserving passthrough detection, RRF seeding, temporal proximity, proof count)
  4. Then overwrites just the recency component with the sigmoid, recalculating combined_score and weight

This means everything about the existing scoring is preserved — the sigmoid only replaces the sr.recency linear decay and the recency_boost multiplicative factor.

Diff (as currently deployed)

# ═══════════════════════════════════════════════════════════════════════
# Monkey-patch: replace linear recency decay with sigmoid.
# Flat top for ~6 weeks, crosses neutral at ~79 days, floor at ~9 months.
# Remove this block to revert to upstream behavior.
# ═══════════════════════════════════════════════════════════════════════
import math as _math
from datetime import timezone as _utc


def _patch_hindsight_recency():
    import hindsight_api.engine.search.reranking as _r

    _original = _r.apply_combined_scoring

    def _sigmoid_recency(
        scored_results,
        now,
        recency_alpha=0.2,
        temporal_alpha=0.2,
        proof_count_alpha=0.1,
        is_passthrough_reranker=False,
    ):
        # Run the original first (handles passthrough detection, RRF seeding,
        # temporal proximity, proof count — all unchanged).
        _original(
            scored_results,
            now,
            recency_alpha,
            temporal_alpha,
            proof_count_alpha,
            is_passthrough_reranker,
        )

        if now.tzinfo is None:
            now = now.replace(tzinfo=_utc)

        for sr in scored_results:
            # Compute sigmoid recency boost [0.60, 1.10]
            recency_boost = 1.0
            if sr.retrieval.occurred_start:
                occurred = sr.retrieval.occurred_start
                if occurred.tzinfo is None:
                    occurred = occurred.replace(tzinfo=_utc)
                days_ago = (now - occurred).total_seconds() / 86400
                recency_boost = 0.60 + 0.50 / (
                    1 + _math.exp(-0.03 * (125 - days_ago))
                )

            # Re-score with sigmoid recency
            sr.temporal = (
                sr.retrieval.temporal_proximity
                if sr.retrieval.temporal_proximity is not None
                else 0.5
            )
            proof_count = sr.retrieval.proof_count
            if proof_count is not None and proof_count >= 1:
                proof_norm = min(
                    1.0,
                    max(0.0, 0.5 + (_math.log(proof_count) / 10.0)),
                )
            else:
                proof_norm = 0.5

            temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
            proof_count_boost = 1.0 + proof_count_alpha * (proof_norm - 0.5)
            sr.combined_score = (
                sr.cross_encoder_score_normalized
                * recency_boost
                * temporal_boost
                * proof_count_boost
            )
            sr.weight = sr.combined_score

    _r.apply_combined_scoring = _sigmoid_recency


_patch_hindsight_recency()
del _patch_hindsight_recency
# ═══════════════════════════════════════════════════════════════════════

Suggested integration path (for maintainers)

The sigmoid parameters (floor, ceiling, midpoint, steepness) should be exposed as env vars following Hindsight's existing pattern:

Env var Default Description
HINDSIGHT_API_RECENCY_DECAY_FUNCTION "linear" "linear" (current) or "sigmoid" (new)
HINDSIGHT_API_RECENCY_SIGMOID_FLOOR 0.60 Minimum recency boost for old memories
HINDSIGHT_API_RECENCY_SIGMOID_CEILING 1.10 Maximum recency boost for fresh memories
HINDSIGHT_API_RECENCY_SIGMOID_MIDPOINT 79 Days at which recency crosses neutral (1.0)
HINDSIGHT_API_RECENCY_SIGMOID_STEEPNESS 0.03 How quickly the curve transitions

These would go in HindsightConfig as hierarchical fields (bank-configurable), with apply_combined_scoring reading them via get_config() or receiving them as parameters.

The monkey-patch approach was necessary because the Hermes plugin loads after Hindsight is installed, and apply_combined_scoring is called directly from memory_engine.py:4557 without any config parameter passthrough for the decay function.

Testing

This patch has been running in production on a Hermes agent instance for ~3 weeks (since May 31, 2026) with no issues. The sigmoid correctly suppresses stale operational memories (~6 weeks old) while preserving recent context.


Submitted by garyd (discord: Pinched-Nerve) via the Hermes agent integration. Happy to discuss the approach but unlikely to be the right person to properly integrate this into the config system.

Replace linear recency decay (1.0 - days/365) with a sigmoid curve:
- Flat top for ~6 weeks (memories stay sharp)
- Crosses neutral at ~79 days
- Floor at 0.60 after ~9 months

Submitted in monkey-patch form for discussion. A maintainer should
properly integrate the sigmoid parameters into the config system
(env vars, hierarchical config) before merging.

Signed-off-by: garyd9 <garyd9@users.noreply.github.com>
@garyd9

garyd9 commented Jun 18, 2026

Copy link
Copy Markdown
Author

Stupid mimo-v2.5-pro doesn't seem to understand "submit this as a monkey patch." No, it had to actually patch the source and submit that. Sorry. I think the PR is still usable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant