Forgetting and decay in agent memory
Forgetting and decay is a memory-management technique that lowers the strength of a stored memory while it goes unused and takes it out of the active store when a rule says it is no longer worth keeping.
Last updated: 09 Oct, 2026 · NumPy 2.5
More memory is not always better. As the video puts it, an agent that remembers everything becomes slower to retrieve from, carries noise into its replies, and cannot tell what matters now from what mattered two years ago. Forgetting means pruning the low-value, stale and redundant memories so that a long-running store stays small and relevant.
This part of the video starts at 5:48:22 and reads the key concepts of the notebook 13_forgetting_and_decay.ipynb. The half-life is a fixed setting, the time in which strength halves; the value that moves is the strength, which falls while a memory sits unused and goes back up when the memory is read. The notebook's code uses a half-life of 30 days.
The forgetting curve and the half-life form
In 1885 Hermann Ebbinghaus measured how fast he forgot lists of syllables he had learned. The usual textbook model of his forgetting curve is an exponential:
S is the time after which retention has fallen to 1/e, about 37%. Code more often writes the same curve with a half-life h, the time after which strength has fallen to one half:
The two forms draw one curve. Setting them equal gives the link between the two constants:
The notebook's key-concepts cell states the first form. Its code computes 0.5 ** (days_idle / 30), the second form with a 30-day half-life, where the days are counted from the last time the memory was read.
import math
h = 30 # half-life in days
S = h / math.log(2) # the stability that draws the same curve
lam = math.log(2) / h # decay rate per day
print(f"h = {h} days -> S = h / ln 2 = {S:.2f} days, lambda = ln 2 / h = {lam:.4f} per day")
print()
print("idle days | 0.5^(t/h) | e^(-t/S) | e^(-lambda t)")
for t in (3, 7, 10, 30, 60, 90):
print(f"{t:9} | {0.5 ** (t / h):9.4f} | {math.exp(-t / S):8.4f} | {math.exp(-lam * t):13.4f}")
print()
print(f"after one half-life (t = h = {h} days): {0.5 ** (h / h):.4f}")
print(f"after one stability (t = S = {S:.2f} days): {math.exp(-S / S):.4f}")
print()
print("a 24-hour half-life after 1, 2, 3 and 7 days:", [round(0.5 ** days, 4) for days in (1, 2, 3, 7)])
print(f"e^(-t/30) after 30 days: {math.exp(-30 / 30):.4f}, so its half-life is 30 x ln 2 = {30 * math.log(2):.2f} days")h = 30 days -> S = h / ln 2 = 43.28 days, lambda = ln 2 / h = 0.0231 per day
idle days | 0.5^(t/h) | e^(-t/S) | e^(-lambda t)
3 | 0.9330 | 0.9330 | 0.9330
7 | 0.8507 | 0.8507 | 0.8507
10 | 0.7937 | 0.7937 | 0.7937
30 | 0.5000 | 0.5000 | 0.5000
60 | 0.2500 | 0.2500 | 0.2500
90 | 0.1250 | 0.1250 | 0.1250
after one half-life (t = h = 30 days): 0.5000
after one stability (t = S = 43.28 days): 0.3679
a 24-hour half-life after 1, 2, 3 and 7 days: [0.5, 0.25, 0.125, 0.0078]
e^(-t/30) after 30 days: 0.3679, so its half-life is 30 x ln 2 = 20.79 daysWhat the two forms give
- A 30-day half-life is a stability of 43.28 days and a decay rate of 0.0231 per day.
- The three columns agree on every row: 0.9330 after 3 idle days, 0.5000 after 30, 0.2500 after 60, 0.1250 after 90. They are one curve.
- The constants mark different points. After one half-life the strength is 0.5000; after one stability it is 0.3679.
- A 24-hour half-life, the example read out in the video, leaves 0.5 after one day, 0.25 after two, 0.125 after three and 0.0078 after a week.
- A 30 in the exponent is not always a half-life.
e^(-t/30)is at 0.3679 after 30 days, and its half-life is 20.79 days.
Plotting decay for several half-lives
The half-life is the tuning knob: short for facts that go stale fast, long for facts that last. A threshold turns the curve into a decision. Under it, the memory is a candidate for pruning.
import numpy as np
import matplotlib.pyplot as plt
THRESHOLD = 0.25 # prune below this strength
days = np.linspace(0, 120, 481)
plt.figure(figsize=(8, 4.2))
for h, color in [(7, "tab:red"), (30, "tab:blue"), (90, "tab:green")]:
plt.plot(days, 0.5 ** (days / h), color=color, label=f"half-life {h} days")
cross = h * np.log2(1 / THRESHOLD) # day the curve reaches the threshold
print(f"half-life {h:2} days: strength 0.5 at day {h}, reaches {THRESHOLD} at day {cross:.0f}")
plt.axhline(THRESHOLD, color="gray", linestyle="--", label=f"pruning threshold {THRESHOLD}")
plt.scatter([30], [0.5], color="tab:blue", zorder=3)
plt.title("Memory strength 0.5^(t/h) for three half-lives")
plt.xlabel("days since the memory was last used")
plt.ylabel("strength")
plt.ylim(0, 1.02)
plt.xlim(0, 120)
plt.legend()
plt.grid(alpha=0.3)
plt.show()half-life 7 days: strength 0.5 at day 7, reaches 0.25 at day 14 half-life 30 days: strength 0.5 at day 30, reaches 0.25 at day 60 half-life 90 days: strength 0.5 at day 90, reaches 0.25 at day 180
With a threshold of 0.25 a memory lasts two half-lives without being read: 14 days at a half-life of 7, 60 days at 30 and 180 days at 90, which is beyond the right edge of the plot.
Reinforcement: a read resets the clock
In the notebook, reading a memory calls record_access(), which sets last_accessed to now and adds one to access_count. Strength is computed from the idle time, so a read puts it back to 1.0. Another common design adds a fixed boost per read, capped at 1.0, instead of a full reset.
def strength(day, read_days, half_life=30):
last = max([0] + [d for d in read_days if d <= day]) # the last day it was read
return 0.5 ** ((day - last) / half_life) # decay counts from that dayimport numpy as np
import matplotlib.pyplot as plt
H, THRESHOLD = 30, 0.25 # half-life in days, pruning threshold
READ_DAYS = [30, 60] # the days the second memory is read
def strength(day, read_days):
last = max([0] + [d for d in read_days if d <= day]) # day 0 is when it was written
return 0.5 ** ((day - last) / H)
print("day | never read | read on days 30 and 60")
for day in (0, 15, 29, 30, 45, 59, 60, 61, 90):
print(f"{day:3} | {strength(day, []):10.3f} | {strength(day, READ_DAYS):10.3f}")
days = np.arange(0, 120.25, 0.25)
plt.figure(figsize=(8, 4.2))
plt.plot(days, [strength(d, []) for d in days], color="tab:red", label="never read")
plt.plot(days, [strength(d, READ_DAYS) for d in days], color="tab:blue", label="read on days 30 and 60")
plt.axhline(THRESHOLD, color="gray", linestyle="--", label=f"pruning threshold {THRESHOLD}")
plt.title("A read resets the clock (30-day half-life)")
plt.xlabel("days since the memory was written")
plt.ylabel("strength")
plt.ylim(0, 1.02)
plt.xlim(0, 120)
plt.legend()
plt.grid(alpha=0.3)
plt.show()day | never read | read on days 30 and 60 0 | 1.000 | 1.000 15 | 0.707 | 0.707 29 | 0.512 | 0.512 30 | 0.500 | 1.000 45 | 0.354 | 0.707 59 | 0.256 | 0.512 60 | 0.250 | 1.000 61 | 0.244 | 0.977 90 | 0.125 | 0.500
What the two memories show
- The memory nobody reads is at 0.500 on day 30, at 0.250 on day 60 and at 0.244 on day 61, under the threshold.
- The memory read on days 30 and 60 is back at 1.000 on both days and still at 0.500 on day 90.
- Same half-life, same age. Use alone decides which of the two is kept: this is the spaced repetition idea, each review restarting the curve.
The importance score from the video
Recency alone would throw away a rule the user set long ago. The table at the end of the video comes from a score that mixes three signals:
- Recency is the decay above: 0.5 to the power of idle days over 30.
- Frequency is the number of times the memory was read, divided by 20 and capped at 1.
- Category weight is set by hand: 1.0 for a constraint, 0.85 for a goal, 0.75 for a salary, 0.65 for an investment preference, 0.35 for a session note, 0.20 for a market view.
- A protected memory skips the formula and scores 1.0. Constraints and personal facts are protected automatically.
- Under 0.25 a memory is a candidate for eviction.
recency = 0.5 ** (idle_days / 30) # 30-day half-life
frequency = min(accesses / 20, 1.0) # 20 accesses or more count as 1.0
weight = CATEGORY_WEIGHT[category] # set by hand per category
total = 1.0 if protected else 0.4 * recency + 0.3 * frequency + 0.3 * weightThe notebook builds each memory with timestamps counted back from datetime.now(). The code here passes the idle days as plain numbers, which gives the same values on any day.
CATEGORY_WEIGHT = {"constraint": 1.0, "goal": 0.85, "salary": 0.75,
"investment_pref": 0.65, "session_note": 0.35, "market_view": 0.20}
THRESHOLD = 0.25
# (label, category, days since last access, times accessed, protected)
scenarios = [
("New salary (fresh)", "salary", 3, 1, False),
("Old salary (18 months)", "salary", 540, 0, False),
("Active SIP pref (weekly)", "investment_pref", 7, 8, False),
("Old market view (2mo)", "market_view", 60, 0, False),
("Constraint (never equity)", "constraint", 90, 2, True),
("Old session note (1mo)", "session_note", 30, 0, False),
("Freq-accessed goal", "goal", 10, 15, False),
]
print(f"{'Label':>26} | Recency | Freq | CatW | TOTAL | Keep?")
for label, category, idle, accesses, protected in scenarios:
recency = 0.5 ** (idle / 30) # 30-day half-life
frequency = min(accesses / 20, 1.0) # 20 accesses or more count as 1.0
weight = CATEGORY_WEIGHT[category]
formula = 0.4 * recency + 0.3 * frequency + 0.3 * weight
total = 1.0 if protected else formula
keep = "KEEP" if total >= THRESHOLD else "EVICT"
print(f"{label:>26} | {recency:7.3f} | {frequency:5.3f} | {weight:5.3f} | {total:5.3f} | {keep}")
if protected:
print(f"{'(by the formula alone)':>26} | {'':7} | {'':5} | {'':5} | {formula:5.3f} |") Label | Recency | Freq | CatW | TOTAL | Keep?
New salary (fresh) | 0.933 | 0.050 | 0.750 | 0.613 | KEEP
Old salary (18 months) | 0.000 | 0.000 | 0.750 | 0.225 | EVICT
Active SIP pref (weekly) | 0.851 | 0.400 | 0.650 | 0.655 | KEEP
Old market view (2mo) | 0.250 | 0.000 | 0.200 | 0.160 | EVICT
Constraint (never equity) | 0.125 | 0.100 | 1.000 | 1.000 | KEEP
(by the formula alone) | | | | 0.380 |
Old session note (1mo) | 0.500 | 0.000 | 0.350 | 0.305 | KEEP
Freq-accessed goal | 0.794 | 0.750 | 0.850 | 0.797 | KEEPReading the importance table
- Every value equals the table on the video's screen.
- The old salary has been idle for 540 days, 18 half-lives, so its recency prints as 0.000. Its total of 0.225 is the category term alone, and it is under 0.25: EVICT.
- The old market view is 60 days idle, two half-lives, recency 0.250. With the lowest category weight it totals 0.160: EVICT.
- The active SIP preference was read 8 times, the last time a week ago: 0.655, KEEP.
- The constraint was last read 90 days ago and would score 0.380 by the formula alone. It is protected, so it scores 1.000 and no strategy touches it.
- The session note is at 0.305 after one idle month: kept for now, and falling.
This part of the video starts at 5:44:41 and goes through the strategies one by one. TTL uses no score: a memory expires when its age passes the fixed lifetime of its category, and scores belong to the third strategy.
Four forgetting strategies
- TTL, time to live. Every memory has a fixed lifetime, and when its age passes it the memory is deleted or archived. Simple, predictable, bounded storage. It suits high-churn data such as market views, session notes and search results. Rare but critical facts, a user's allergy or a hard constraint, must not age out on a schedule. The notebook's lifetimes: market view 7 days, session note 14, general 60, investment preference 90, salary 180, risk profile 365, goal 730, constraints and personal facts never.
- LRU, least recently used. Evict the memories that have gone longest without being read; each read resets the clock. It keeps what is in use, the way an operating system cache does, and has the same weak spot as TTL: a critical fact that was not retrieved lately is pruned.
- Importance-weighted eviction. Score every memory and remove the lowest scores first. It looks at more than time, and its weights need tuning. The notebook bases the score on arXiv 2604.02280, "Novel Memory Forgetting Techniques for Autonomous AI Agents", which combines recency, frequency and semantic alignment; the notebook uses a hand-set category weight in place of semantic alignment.
- Budget-constrained pruning. Set a hard cap on the store (a number of memories, tokens or bytes). When the store is over the cap, the lowest-importance memories go until it fits. Storage stays bounded however long the agent runs; the cap has to be chosen at design time.
Running the four strategies in order
The notebook runs them cheapest first: TTL, then LRU, then importance, then budget. Each pass looks only at what the earlier ones left, and none of them may touch a protected memory.
def evict(strategy, should_go):
gone = [m for m in active if m["cat"] not in PROTECTED and should_go(m)]
for m in gone:
active.remove(m) # out of the active store
evict("TTL", lambda m: TTL_DAYS[m["cat"]] is not None and m["age"] > TTL_DAYS[m["cat"]])
evict("LRU", lambda m: m["idle"] > LRU_IDLE_DAYS)
evict("importance", lambda m: importance(m) < THRESHOLD)The example runs the four passes over ten memories with an LRU limit of 60 idle days, a threshold of 0.25 and a budget of five. Ages and idle times are plain numbers, so the output does not depend on the day it runs. The number after each archived memory is its importance score.
TTL_DAYS = {"market_view": 7, "session_note": 14, "general": 60, "investment_pref": 90,
"salary": 180, "goal": 730, "constraint": None}
WEIGHT = {"constraint": 1.0, "goal": 0.85, "salary": 0.75, "investment_pref": 0.65,
"general": 0.45, "session_note": 0.35, "market_view": 0.20}
PROTECTED = {"constraint"}
LRU_IDLE_DAYS, THRESHOLD, BUDGET = 60, 0.25, 5
rows = [ # text, category, age in days, idle days, times read
("Never invest in equity for me", "constraint", 180, 90, 2),
("Goal: retire at 55", "goal", 200, 75, 6),
("Salary is Rs 1,50,000 at TCS", "salary", 3, 3, 1),
("Salary is Rs 90,000 at Infosys", "salary", 540, 540, 0),
("SIP of Rs 5,000 in a liquid fund", "investment_pref", 30, 7, 8),
("Prefers short-duration debt funds", "investment_pref", 80, 20, 4),
("Markets look volatile this week", "market_view", 60, 60, 0),
("Bond yields are rising", "market_view", 5, 5, 0),
("Compared NPS and PPF in this session", "session_note", 10, 10, 0),
("FD of Rs 50,000 matures in 3 months", "general", 58, 58, 0),
]
active = [dict(zip(("text", "cat", "age", "idle", "reads"), r)) for r in rows]
def importance(m):
return 0.4 * 0.5 ** (m["idle"] / 30) + 0.3 * min(m["reads"] / 20, 1.0) + 0.3 * WEIGHT[m["cat"]]
def evict(strategy, should_go):
gone = [m for m in active if m["cat"] not in PROTECTED and should_go(m)]
for m in gone:
active.remove(m)
print(f"{strategy:<10} archived {len(gone)}:", ", ".join(f"{m['text']} ({importance(m):.3f})" for m in gone))
evict("TTL", lambda m: TTL_DAYS[m["cat"]] is not None and m["age"] > TTL_DAYS[m["cat"]])
evict("LRU", lambda m: m["idle"] > LRU_IDLE_DAYS)
evict("importance", lambda m: importance(m) < THRESHOLD)
over = max(len(active) - BUDGET, 0)
lowest = sorted((m for m in active if m["cat"] not in PROTECTED), key=importance)[:over]
evict("budget", lambda m: m in lowest)
print()
for m in active:
score = 1.0 if m["cat"] in PROTECTED else importance(m)
print(f"kept {score:.3f} {m['text']}")TTL archived 2: Salary is Rs 90,000 at Infosys (0.225), Markets look volatile this week (0.160) LRU archived 1: Goal: retire at 55 (0.416) importance archived 1: FD of Rs 50,000 matures in 3 months (0.240) budget archived 1: Bond yields are rising (0.416) kept 1.000 Never invest in equity for me kept 0.613 Salary is Rs 1,50,000 at TCS kept 0.655 SIP of Rs 5,000 in a liquid fund kept 0.507 Prefers short-duration debt funds kept 0.422 Compared NPS and PPF in this session
What each strategy archived
- TTL archived 2: the Infosys salary, 540 days old against a salary lifetime of 180, and the market view, 60 days old against 7.
- LRU archived the retirement goal. It was read 6 times and scores 0.416, well above 0.25, but its last read was 75 days ago and the limit is 60. LRU runs before importance and never asks for the score.
- Importance archived the FD note at 0.240: 58 days old, 58 days idle, never read, and inside both the 60-day TTL of its category and the LRU limit.
- Budget archived one more. Six memories were left for a budget of five, so the lowest unprotected score, the bond-yield view at 0.416, went.
- The constraint stayed at 1.000. It has been idle for 90 days, which LRU would have taken, and it is protected.
- The lost goal is the failure to plan for. A fact that is rarely read and badly needed when it is read looks like dead weight to TTL and LRU. Protect such categories, or exempt them from the time-based passes.
TTL vs LRU vs importance vs budget
| Strategy | Looks at | Evicts when | What it gets wrong |
|---|---|---|---|
| TTL | Age since the memory was written | Age is over the lifetime of its category | A lasting fact filed under a short-lived category |
| LRU | Time since the last read | Idle time is over the limit | A critical fact that is rarely needed |
| Importance | Recency, read count, category weight | The score is under the threshold | Whatever the hand-set weights get wrong |
| Budget | The size of the store | The store is over its cap | Useful memories, when the cap is too small |
Where you use forgetting and decay
- Assistants that run for months. Without pruning, every retrieval searches through years of stale facts.
- Facts with a short shelf life. Market views, prices and session notes get a TTL in days.
- Stores with a cost or latency limit. A budget bounds the store whatever the users do.
- Not for erasure requests. When a user asks for their data to be deleted, every store is cleared for that user at once, archive included. Decay plays no part in that.
exp(-t/30) halves in 20.79 days, not 30.Related
- Previous: Memory routing
- Next: Securing agent memory
- See also: Sliding window memory
- Reference: Murre and Dros (2015), Replication and Analysis of Ebbinghaus' Forgetting Curve
- In the first example set
h = 7. The first line becomes S = 10.10 days and lambda = 0.0990 per day. - In the importance table change the session note's idle days from 30 to 45. Its recency drops to 0.354, its total to 0.246, and the verdict to EVICT.
- In the last example set
PROTECTED = {"constraint", "goal"}. LRU now archives nothing, the goal stays at 1.000, and the budget pass archives two memories instead of one.
This is what real progress feels like.