TL;DR:

Key Takeaways

The issue here is structural, not something you can tune your way out of. With a fixed recency window, you hit a hard mathematical wall: if you don’t touch an item within window_size turns, it’s wiped from memory. It doesn’t matter if it was the most heavily used item in the system five minutes ago—once it slips past that window, it’s gone.

Second, reinforcement compounds where recency fails. By letting retention scale with recall frequency, the decay engine pushes an item’s effective survival horizon out by orders of magnitude based on just a few early interactions.

Third, let’s be clear: this isn’t “better memory” across the board. To make sure this wasn’t a benchmark artifact, I built a failure-case test. When a fact is introduced once and never recalled, the decay engine performs identically to the baseline. That is the honest boundary of this mechanism.

Fourth, it is entirely deterministic. Everything relies on an explicit turn counter, not wall-clock time. To verify, I ran the suite on two different machines and operating systems—the diffed outputs were byte-identical.

Finally, always audit your benchmarks. Two hidden bugs almost invalidated this entire run. I’m breaking them down because catching these issues is the difference between data you can trust and data that quietly lies to you.

Complete code: https://github.com/Emmimal/memory-decay-engine/

The Fact That Outlived Its Own Window

Let’s look at how this actually plays out in practice. Imagine you are 150 turns into a session with an agent. On the very first turn, the user gives the agent a core rule, like a compliance requirement or a specific tech stack constraint. The agent references it a few times during the first thirty turns while getting oriented, but then the conversation moves on. For the next hundred turns, the agent is busy doing other things, like parsing logs, making one-off tool calls, and handling random side tasks.

Later in the session, well past that early window, you ask a question that depends entirely on that rule from turn one.

With a standard sliding window, you have already lost that rule. The window doesn’t know the rule is critical, and it doesn’t care that the agent already used it five times. It just sees a turn count that got too high and drops the data. That isn’t memory. It is just a countdown timer.

I ran this exact scenario across 50 seeded sessions. In this benchmark, the decay engine retained every foundational fact through the end of the session, while the recency-only baseline retained none. The result was consistent across all 50 seeds. Here is why that happens.

Who This Is For

This approach applies to any system where an agent needs to keep context across a long, multi-turn session. Think of coding agents working on multi-day tasks, customer support bots handling extended troubleshooting threads, autonomous research loops, or any pipeline where memory has to mean more than just the last few messages.

It matters most when your sessions follow a specific pattern. Something important gets established right at the start, then goes quiet for a long stretch while other work happens, and needs to still be active when it finally resurfaces.

You can skip this entirely if your sessions are short enough that everything fits into the context window anyway. You also do not need it if every fact in your session has roughly equal relevance the whole time. A simple window works fine for that, and there is no point building a solution for a problem you do not have.

Why This Matters for Real Systems

This is not just an abstract issue for benchmarks. You see this exact pattern play out in production all the time.

Take coding agents as an example. If an agent is working on a task over several days, it might get a strict instruction on day one, like a specific library version to use or a file it should never edit. Once the session gets too long, a naive recency window just drops that instruction, and the agent breaks your codebase.

Or take customer support bots. If a troubleshooting thread goes on too long, the back-and-forth piles up until the bot completely forgets the user’s account details or the actual problem they started with. Next thing you know, the bot is asking questions the user already answered ten minutes ago.

You see the same issue in RAG pipelines. The system aggressively throws out facts just because they haven’t been mentioned in the last few turns. It doesn’t matter if those facts are central to the entire task and crucial for the final steps; if they aren’t active right now, they get pruned.

When these systems fail, they don’t crash or throw clean errors. They just quietly hand you a wrong or incomplete answer because the one piece of information that actually mattered aged out of the system.

A Confession Before I Go Further

I have built decay before. In an earlier project [6], I set up exponential time-decay for a RAG document corpus. It was a simple curve that down-ranked stale documents at retrieval time based on their age in days. If you read that piece, the math here might look similar at first glance. But I want to be clear about the difference before you think I am just packaging up the same old idea.

That old approach only went one way, and that was down. The system did not know if a document actually still mattered. A policy document retrieved fifty times decayed at the exact same rate as one nobody ever looked at again, because the only input to the formula was calendar age. It was also solving a completely different problem. It was ranking documents in a persistent corpus, not deciding what gets to survive in a single session’s working memory.

This project adds the piece I missed before, which is usage. When an item gets recalled here, it does not just get a temporary boost for one query. Its underlying stability changes permanently, reshaping its entire future decay curve. That is a completely different mechanism, and it is the entire reason I am writing this instead of just adding a footnote to the old post.

Why Ebbinghaus, Specifically

I did not invent the math behind this. It is actually about 140 years old.

Back in 1885, a psychologist named Hermann Ebbinghaus ran the first real experiments on how human memory fades [1]. He used himself as the test subject, memorizing nonsense syllables to see how long they would stick. His main finding was that our retention drops off incredibly fast at first, but every time you review the information, that forgetting curve flattens out. That concept still drives modern spaced-repetition research today, including major reviews on how spacing affects verbal recall [2].

I am definitely not the first person to connect this idea to AI agents. Recently, several open-source projects have adopted the Ebbinghaus forgetting curve for agent memory, including the YourMemory project by Mishra [4]. The project benchmarks this approach on the LoCoMo long-term conversational memory dataset [3] and reports approximately a 16-percentage-point improvement in recall over a comparable memory tool. Stanford’s Generative Agents paper took a related approach, although it combined recency, importance, and relevance rather than relying on a pure forgetting curve [5].

My goal here was not to build something totally novel. I am not claiming this is a brand new concept. I just wanted to see if I could build the absolute smallest, fully deterministic version of this myself with zero dependencies, run it against the basic sliding window most systems actually use, and find the exact point where it breaks.

The Two Failure Modes, Side by Side

Before diving into the code, it is worth looking at exactly what we are comparing. The rest of this piece depends on this distinction.

| Recency-Only Baseline | Ebbinghaus Decay Engine | |
|---|---|---|
| Tracks | last_touched_turnonly | last_touched_turn,stability,recall_count |
| Eviction rule | age > window_size→ evict (fixed cutoff) | retention(t) < threshold→ evict, whereretention = e^(-t / stability) |
| Effect of 10 recalls vs. 0 recalls | None: only the most recenttouch matters | Stability compounds non-linearly with every recall; heavily-recalled items get a dramatically longer eviction horizon |

This baseline is not a strawman. Most production systems run on this baseline because it is incredibly cheap and takes almost no effort to implement. But things break the moment your session matches the pattern we looked at earlier. If a core rule is established right at the start, used a few times, and then left untouched for dozens of turns, the system just throws it away.

The Architecture

The setup uses two classes that share the exact same interface. The only difference between them is how they decide to throw away data.

The Scoring Formula

At any elapsed turn-distance t, the retention score for an item with stability S is calculated as:

Ret = e^(-t / S)Every time an item is recalled, its stability reinforces non-linearly:

S_new = S_old × (1 + ln(1 + recall_count))The system evicts an item the moment its retention score drops below a set threshold (which defaults to 0.20, though we will look at 0.10 and 0.30 later). Stripped down to its core logic, the eviction check looks like this in Python:

score = math.exp(-elapsed / item.stability) if elapsed > 0 else 1.0 if score < eviction_threshold: evict(mem_id)
The baseline’s eviction check is a single comparison, no exponential involved:

if (current_turn - item.last_touched_turn) > window_size: evict(mem_id).One specific choice here is that every turn relies on an explicit integer counter instead of time.time(). My first draft actually used wall-clock time, which turned out to be a major mistake. We will get into why that broke in the bugs section below.

Because both engines expose the exact same five methods (register, recall, step, is_present, and working_set_size), the simulation harness driving the tests does not know or care which engine it is running. Any variation in the final results comes down entirely to the eviction policy itself.

Visualizing the Divergence

Here is what separates the two mechanisms. This graph plots real data from the project, showing the retention score against the number of turns since an item was last touched.

You can see the difference between a single recall (no reinforcement, where stability stays at the baseline of 8) and an item recalled four times (where stability compounds to roughly 292):

Both items start in the exact same spot with a retention score of 1.0. But by turn 15, the plain item has already dropped to 0.153, falling past our 0.20 threshold. Meanwhile, the reinforced item is still sitting up at 0.950. We are using the exact same math and the exact same threshold for both here. The only difference is that the reinforced item had its stability boosted during those early recalls.

The sliding window baseline is that vertical line cutting straight through. It completely ignores both of these curves and drops the data at a fixed cutoff, no matter how stable either memory actually is.

Two Bugs That Almost Lied to Me

I want to break down these two bugs, much like how I handled that tri-state bug in the last project. Catching things like this before you trust your benchmark numbers is the whole point of doing a sanity-check pass.

Bug 1: The Grace Period That Wasn’t

Initially, I set baseline_stability to 2.0. It seemed like a reasonable default on paper. But then I plotted the math to see exactly when a newly registered, never-recalled item would trigger an eviction.

baseline_stability=2.0 → naive eviction at turn 3.22 baseline_stability=4.0 → naive eviction at turn 6.44 baseline_stability=6.0 → naive eviction at turn 9.66 baseline_stability=8.0 → naive eviction at turn 12.88
With S = 2.0, a core fact registered at turn 1 decayed past our 0.20 threshold by turn 3. That was before the very first scheduled recall of that fact even took place. The engine was throwing away facts before they ever had a chance to get reinforced, making the entire mechanism dead on arrival.

I caught this by calculating the eviction turn across a range of stability values before running a single seeded session, rather than staring at final benchmark averages and guessing why the numbers looked off. I bumped the default to S = 8.0, which gives a clean 13-turn grace period—plenty of time for a fact to survive until its first reinforcement.

Bug 2: A Benchmark That Was Secretly Impossible to Pass

The second bug was subtler and, honestly, more embarrassing. In my first version of the synthetic session generator, I scheduled recall turns using pure uniform-random sampling across the entire session length. For a 150-turn session, that meant the first recall of a core fact could land anywhere from turn 10 to turn 150. More often than not, it landed 30, 40, or even 60 turns after registration.

Both engines have some kind of grace period before an untouched item gets evicted. When I ran the benchmark with this random scheduling, the recency baseline scored exactly 0.000 across all 50 seeds, with zero standard deviation.

My first reaction was that this looked like a major win. My second reaction, the one that actually saved me—was that a result with zero variance across 50 random seeds is a massive red flag, not a victory. A benchmark that produces the exact same outcome regardless of its random seed usually means the randomness is not actually reaching the part of the system you are trying to measure.

I traced it back: with recalls scattered uniformly at random, the first recall itself almost always arrived long after both engines’ grace periods had expired. I was not measuring whether reinforcement helps retention. I was just measuring whether the random number generator happened to schedule a recall early enough to matter, and the answer was almost always “no” for both systems. The baseline was not failing because it was a worse policy; it was failing because the test scenario made success structurally impossible.

I fixed this by ditching the uniform-random recall placement and replacing it with a schedule that mimics actual spaced-repetition intervals: a short gap before the first recall (3 turns), followed by widening gaps for subsequent recalls (8, 20, 45, 90, and 150 turns out). I also added ±30% multiplicative jitter so that no two seeds produce an identical shape.

This gives us a much more realistic model of how a fact actually gets used in a real session: referenced soon after it is stated, and then progressively less often as it becomes established. This is the schedule every number in this article is built on.

The Benchmark

Every number below is copy-pasted straight from an actual terminal run of benchmark.py without any edits. I ran the entire suite twice on completely separate setups: once on Linux and once on a Windows machine running a different Python environment. A byte-for-byte diff of the outputs came back identical. This isn’t just a claim about reproducibility; it is a verified fact about this codebase.

1. Headline Comparison (N=50 seeds, 150-turn sessions, 3 foundational facts, noise=3/turn)

| Metric | Ebbinghaus Engine | Recency-Only Baseline |
|---|---|---|
| Terminal FRR (mean) | 1.000 (stdev 0.000) | 0.000 (stdev 0.000) |
| Terminal Survival Rate | 1.000 | 0.000 |
| Foundational facts evicted | 0.00 / 3 per session | 3.00 / 3 per session |
| Token-footprint reduction | 0.911 | 0.898 |

Both engines achieve a similar footprint reduction. Around 90% of all created items are kept out of working memory at any given point under both approaches. This makes sense because most of the noise (three throwaway items per turn over 150 turns) should be pruned by any decent policy. The real difference is not how aggressively they prune, but rather what gets caught in the cleanup.

I want to avoid just throwing a clean 1.000-vs-0.000 split at you and moving on. A perfect split with zero variance across 50 seeds can look fake even when the code actually produced it. Here is the exact mechanism behind those numbers, verified directly from the logs. After four recalls spread over the first 45 turns, a single item’s stability climbs to roughly 292. By turn 150 (which is 111 turns after it was last touched), its retention score sits at 0.684. That is still well above the 0.20 eviction threshold.

2. Noise Resistance Sweep (N=20 seeds per level, session length fixed at 150)

| Noise items/turn | Ebbinghaus FRR | Baseline FRR |
|---|---|---|
| 1 | 1.000 | 0.000 |
| 5 | 1.000 | 0.000 |
| 10 | 1.000 | 0.000 |
| 20 | 1.000 | 0.000 |

I want to be clear about what this table actually shows. It proves the results are not sensitive to background noise. The Ebbinghaus engine keeps its edge even when noise increases twentyfold.

This doesn’t mean noise doesn’t matter. Both engines have to work much harder to prune the clutter at higher noise levels, but the outcome for our core fact does not budge.

I am presenting this as a table instead of squeezing the data into a single slope value. Because FRR is strictly bounded between 0 and 1, assuming a linear relationship would tell a misleading story.

3. Sensitivity Sweep (N=20 seeds per configuration, 15 threshold × window combinations tested)

| Threshold | Window | Ebbinghaus FRR | Baseline FRR | Result holds? |
|---|---|---|---|---|
| 0.10 | 10 | 1.000 | 0.000 | Yes |
| 0.20 | 15 (default) | 1.000 | 0.000 | Yes |
| 0.30 | 20 | 1.000 | 0.000 | Yes |
| 0.20 | 30 | 1.000 | 0.000 | Yes |
| 0.20 | 50 | 1.000 | 0.000 | Yes |

I tested all 15 combinations of those three thresholds and five window sizes. The result held up across the board, not just in the handful of configurations listed above.

I even tried cranking the window size up to 50, which is over triple the default, just to see if the baseline could recover. It still didn’t. The issue isn’t that sliding windows are useless, but rather that a fixed window simply cannot span a massive gap of silence after an early reinforcement. If you blow the window up wide enough to survive a 100-turn dry spell, you lose the ability to prune memory effectively anyway.

4. The Failure Case: Where the Advantage Disappears

This is the section that matters most, and it is usually the exact part most articles skip.

If reinforcement is the core mechanism, then a fact that is stated once and never recalled should not benefit from this engine at all. There is simply nothing to reinforce. I set up this test specifically to find out if the main result was just a generic “this system is better” claim, or a narrower, honest proof of what reinforcement actually buys you.

| Scenario: single-use facts, never recalled after registration | Ebbinghaus FRR | Baseline FRR |
|---|---|---|
| Terminal outcome | 0.000 | 0.000 |

We got a zero-versus-zero split. Without any reinforcement, the Ebbinghaus engine never gets to compound its stability past the baseline. It decays and drops out at basically the same rate as an untouched item in the naive window system.

The mechanism does not rescue facts that nobody ever uses again. It specifically, and only, rewards the facts that actually get reused. That is a much narrower, more honest claim than saying “this is a better memory system.” I would much rather publish the narrower true claim than a broad one that falls apart at its own edge case.

What This Looks Like in Practice

The moment that actually convinced me was not a statistic. It was watching a single session run turn by turn and seeing the exact moment a core fact was requested and just was not there anymore.

On turns 5, 10, and 20, both engines recall the same core fact three times. Up to this point, they look identical. Both show a perfect recall rate at the turn 30 checkpoint.

At turn 36, the sliding window baseline drops the fact. Sixteen turns have passed since it was last touched at turn 20, which is one turn past our window limit of 15.

At turn 39, the session asks for that exact fact again. With the baseline, it is already gone. The system silently checks for the ID, finds nothing, and returns False. There is no crash, no warning, and no log entry. You just get an incomplete or wrong answer, even though the fact was perfectly fine just three turns ago.

With the Ebbinghaus engine, that turn 39 recall succeeds. The stability built up from those three early recalls kept the decay rate shallow, keeping the fact well above the eviction threshold. That fourth recall boosts its stability again, carrying the fact safely to the very end of the session.

| Checkpoint turn | Baseline FRR | Ebbinghaus FRR |
|---|---|---|
| 30 | 1.00 | 1.00 |
| 60 | 0.00 | 1.00 |
| 90 | 0.00 | 1.00 |
| 120 | 0.00 | 1.00 |
| 150 | 0.00 | 1.00 |

Same session, same recall pattern. One ended in a silent, unrecoverable loss. The other ran so smoothly it never even realized there was a risk.

Turning the Trace Into a Timeline

Here is one more way to visualize this divergence. If we map whether our core fact is actually present in the working set across the entire session, we can condense the whole history into a single bar for each engine.

Once a recency-window system drops a fact, the mechanism itself offers no way to get it back. The loss is permanent until something explicitly registers it from scratch.

The decay engine’s bar stays solid not because items never approach the eviction point, but because reinforcement actively pushes that eviction point further out, faster than time can close the gap.

What This Does Not Solve

Here are a few honest limits:

Item count is a token count proxy, not the real thing. This benchmark treats every synthetic item as roughly equal weight for the footprint reduction metric. Real content varies enormously in token length. A production version should weight footprint by actual token count, not item count. I am stating this explicitly rather than letting the footprint numbers imply more precision than they actually have.

Recall is triggered explicitly by the simulation, not inferred. In this benchmark, a recall event happens because the synthetic session generator says it happens. In a real system, you would need to define what counts as a genuine recall. Did this memory item actually help produce an answer, or was it merely present in the context? That detection logic is a separate, harder problem that this project does not touch.

The defaults are tuned, not universal. An eviction threshold of 0.20 and a baseline stability of 8.0 came out of the sensitivity sweep and the grace period calculation, not out of thin air. But they are starting points for this specific session shape—early establishment, a quiet middle, and a late resurfacing—not constants. A session with a different rhythm, like continuous light usage throughout instead of a burst-then-silence pattern, deserves its own sweep before you trust these numbers in production.

This does not resolve conflicting memories. If two contradictory facts both stay reinforced, this engine keeps both. Deciding which one is currently true is a completely different problem than deciding what stays in the working set in the first place.

The Bottom Line

A recency window is not a bad idea implemented poorly. It is a simple, cheap policy designed to answer exactly one question: when was this last touched? That works perfectly for sessions where importance and recency happen to align. It quietly breaks the moment they do not, like when something is established early, used a few times, and still needs to remain true a hundred turns of unrelated noise later.

The fix is not a bigger window. A window wide enough to bridge a hundred-turn silent gap stops being a meaningful pruning mechanism. It just keeps everything and calls it a policy. The fix is asking a different question: not when was this touched, but how much has actually depended on it staying correct? Reinforcement answers that question directly, and it compounds in a way a fixed cutoff structurally cannot.

I would rather you remember that distinction than any single percentage in this piece, and I would also want you to keep the failure case in mind. This is not a claim that decay engines have better memory. It is a claim that they specifically protect what keeps getting used, and offer nothing extra for what does not. Building the test that proved that boundary was, honestly, more useful than the headline number.

Complete Code: https://github.com/Emmimal/memory-decay-engine/

Resources

[1] Ebbinghaus, H. (1885). Über das Gedächtnis: Untersuchungen zur experimentellen Psychologie. Duncker & Humblot. English translation: Ebbinghaus, H. (1913). Memory: A Contribution to Experimental Psychology (H. A. Ruger & C. E. Bussenius, Trans.). Teachers College, Columbia University.

[2] Cepeda, N. J., Pashler, H., Vul, E., Wixted, J. T., & Rohrer, D. (2006). Distributed practice in verbal recall tasks: A review and quantitative synthesis. Psychological Bulletin, 132(3), 354–380. https://doi.org/10.1037/0033-2909.132.3.354

[3] Maharana, A., Lee, D.-H., Tulyakov, S., Bansal, M., Barbieri, F., & Fang, Y. (2024). Evaluating very long-term conversational memory of LLM agents. arXiv. https://arxiv.org/abs/2402.17753

[4] Mishra, S. (2026). YourMemory: Agentic AI memory with Ebbinghaus forgetting curve decay [Computer software]. GitHub. https://github.com/sachitrafa/YourMemory

[5] Park, J. S., O’Brien, J. C., Cai, C. J., Morris, M. R., Liang, P., & Bernstein, M. S. (2023). Generative agents: Interactive simulacra of human behavior. arXiv. https://doi.org/10.48550/arXiv.2304.03442

[6] Alexander, E. P. (2026). RAG Is Blind to Time — I Built a Temporal Layer to Fix It in Production. Towards Data Science. https://towardsdatascience.com/rag-is-blind-to-time-i-built-a-temporal-layer-to-fix-it-in-production/

Disclosure

All code in this article was written by me and is original work, developed and tested on Python 3.12. Benchmark numbers and terminal output shown are from actual runs of sanity_check.py, demo.py, and benchmark.py, and are reproducible by cloning the repository. I independently verified this by running the complete suite on two separate machines and operating systems (Linux and Windows) and diffing the output byte for byte; the results were identical on both. This project has zero external dependencies. All functionality runs on the Python standard library only, with no LLM calls or API keys required anywhere in the benchmark.

The Ebbinghaus decay mechanism is based on the historical forgetting-curve research cited above and follows the same general approach as existing open-source agent-memory projects also cited above; the implementation, benchmark design, session generator, and all specific code in this project are my own original work, independent of any of those codebases. I have no financial relationship with any tool, library, or company mentioned in this article.

If you found this article useful or have thoughts on AI memory systems, I’d love to hear from you.

Connect with me: