# How the Breadth Term in ai-memory's Decay Formula Works

> Understand the breadth term in ai-memory's decay formula. Learn how it uses distinct operator access to retain team-wide knowledge and enhance retention scores.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: deep-dive
- Published: 2026-09-06

---

**The breadth term measures how many distinct operators have accessed a page and multiplies the access component of the retention score by a log-scaled factor controlled by `breadth_weight` to preferentially retain team-wide knowledge.**

In `akitaonrails/ai-memory`, the decay formula determines which pages survive eviction sweeps and which get purged. The breadth term—introduced in the `retention_score_with_breadth` function—adds a second-order signal that rewards pages accessed by many different actors rather than repeatedly by a single user. This guide explains the mathematical implementation, configuration options, and practical impact on memory retention.

## Where the Breadth Term Lives

The core implementation resides in [[`crates/ai-memory-store/src/decay.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/decay.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/decay.rs#L94-L118):

```rust
pub fn retention_score_with_breadth(
    params: &DecayParams,
    age_days: f64,
    access_count: u32,
    days_since_access: Option<f64>,
    salience: Option<f64>,
    distinct_actors: u32,
    breadth_weight: f64,
) -> f64 {
    // protect against invalid configuration
    let breadth_weight = if breadth_weight.is_finite() && breadth_weight >= 0.0 {
        breadth_weight
    } else {
        0.0
    };
    let time_term = salience * (-params.lambda * age_days).exp();

    // ---- breadth calculation ----
    // g(0) = g(1) = 1, then monotonic growth
    let breadth = 1.0
        + breadth_weight * (f64::from(distinct_actors.max(1)) - 1.0).ln_1p();

    let access_term = days_since_access.map_or(0.0, |d| {
        params.sigma
            * (1.0 + f64::from(access_count)).ln()
            * (-params.mu * d).exp()
            * breadth
    });
    time_term + access_term
}

```

## The Three Inputs That Control Breadth

### 1. `distinct_actors`

This is the count of **unique operators** who have accessed the page. If the per-actor tracking table does not exist (legacy pages), this value defaults to `0` or `1`. The higher this number, the stronger the breadth signal—though with diminishing returns due to the logarithmic scaling.

### 2. `breadth_weight` (Configuration)

Set via the `decay_breadth_weight` configuration parameter. This is the dial that controls breadth influence:

- **`0.0`** (default): The breadth term equals `1.0`, making the formula **identical** to the historic `retention_score`. No migration cliff for existing deployments.
- **Positive values**: Each additional actor contributes a multiplicative bonus to the access term. The `ln_1p` scaling prevents runaway scores from large teams.

### 3. The Mathematical Formula

```

breadth = 1 + breadth_weight * ln_1p(distinct_actors - 1)

```

Key properties:

- `ln_1p(x)` computes `ln(1 + x)` with numerical stability for small values
- When `distinct_actors ≤ 1`, `breadth = 1` (neutral multiplier)
- Growth is **sublinear**: each new actor adds less than the previous

## Safety and Edge Case Handling

The implementation guards against misconfiguration in [[`decay.rs`](https://github.com/akitaonrails/ai-memory/blob/main/decay.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/decay.rs#L94-L118):

```rust
let breadth_weight = if breadth_weight.is_finite() && breadth_weight >= 0.0 {
    breadth_weight
} else {
    0.0
};

```

If `breadth_weight` is `NaN`, infinite, or negative, it is silently coerced to `0.0`. This ensures the function never produces `NaN` scores or applies unexpected penalties during sweeps.

## How Breadth Affects Eviction Decisions

The breadth multiplier applies **only to the access term**—the component shaped by `access_count` and `days_since_access`. The `time_term` (salience-weighted age decay) remains unchanged.

Practical implications:

- **Weight `0.0`**: Pages compete on raw access patterns alone. A page with 100 hits from one user scores higher than a page with 10 hits from 10 users.
- **Weight `1.5`**: The 10-user page receives a `1 + 1.5 * ln(10) ≈ 4.45x` multiplier on its access term, potentially keeping it above the `cold_threshold` despite lower raw hits.

This design encodes the intuition that **team-shared knowledge deserves stronger retention** than personally hoarded information.

## Where Breadth Scores Get Computed

| Component | File | Purpose |
|-----------|------|---------|
| **Sweep job** | [[`sweep.rs`](https://github.com/akitaonrails/ai-memory/blob/main/sweep.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-consolidate/src/sweep.rs) | Evicts cold pages using `retention_score_with_breadth` |
| **Breadth aggregation** | [[`reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/reader.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/reader.rs#L3370-L3381) | `access_breadth_for_project` counts distinct actors per page |
| **Curator ranking** | [[`curator.rs`](https://github.com/akitaonrails/ai-memory/blob/main/curator.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-consolidate/src/curator.rs#L136) | Manual review queue ranked by breadth-aware scores |

The sweep job pulls breadth data through `access_breadth_for_scoring` (lines 3370-3381 in [`reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/reader.rs)), which joins the page table against per-actor access records to produce `distinct_actors` counts.

## Working Code Example

```rust
use ai_memory_store::{DecayParams, retention_score_with_breadth};

let params = DecayParams::default();
let age_days = 30.0;
let access_count = 5;
let days_since_access = Some(3.0);
let salience = None;

// Scenario: 4 different team members accessed this page

// 1️⃣ Default weight — historic behavior, no breadth bonus
let score_no_breadth = retention_score_with_breadth(
    &params,
    age_days,
    access_count,
    days_since_access,
    salience,
    4,      // distinct_actors
    0.0,    // breadth_weight
);

// 2️⃣ Enable breadth with moderate weight — team knowledge bonus
let score_with_breadth = retention_score_with_breadth(
    &params,
    age_days,
    access_count,
    days_since_access,
    salience,
    4,
    1.5,
);

// breadth multiplier: 1 + 1.5 * ln(1 + 3) = 1 + 1.5 * 1.386 ≈ 3.08
// The access term roughly triples, helping the page survive eviction
println!("Weight 0.0: {score_no_breadth}");
println!("Weight 1.5: {score_with_breadth}");

```

## Test-Driven Verification

The test suite in [[`access_breadth.rs`](https://github.com/akitaonrails/ai-memory/blob/main/access_breadth.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/tests/suite/access_breadth.rs) validates the backward-compatibility guarantee:

```rust
#[test]
fn breadth_is_identity_at_the_default_weight() {
    let breadth_weight = 0.0;
    let actors = 5;
    let score = retention_score_with_breadth(
        &params, 30.0, 5, Some(3.0), None, actors, breadth_weight
    );
    // Identical to legacy formula when breadth is disabled
    assert_eq!(score, retention_score(&params, 30.0, 5, Some(3.0), None));
}

```

This test ensures that existing deployments can upgrade without reconfiguring and see **no score changes** until they explicitly opt in via `decay_breadth_weight`.

## Summary

- The **breadth term** in ai-memory's decay formula rewards pages accessed by many distinct operators, encoding team-wide utility into retention decisions
- **Mathematical foundation**: `breadth = 1 + breadth_weight * ln_1p(distinct_actors - 1)` produces diminishing returns per additional actor
- **Default safety**: `breadth_weight = 0.0` yields exact backward compatibility with the historic `retention_score` function
- **Configuration**: Set `decay_breadth_weight` to positive values to activate team-knowledge preferencing; invalid values are coerced to `0.0`
- **Scope**: Breadth multiplies only the **access term** (hit count and recency), not the **time term** (age decay)

## Frequently Asked Questions

### What happens if I don't configure `decay_breadth_weight`?

The default value is `0.0`, which makes `breadth = 1.0` for all pages. The `retention_score_with_breadth` function produces **identical scores** to the legacy `retention_score` function. Your existing pages compete for retention exactly as before.

### Can the breadth term reduce a page's score below the legacy formula?

No. Because `distinct_actors.max(1)` ensures at least one actor and `ln_1p` of non-negative inputs is non-negative, the breadth multiplier is always **≥ 1.0** when `breadth_weight ≥ 0`. The term can only increase access-term scores or leave them unchanged.

### How does ai-memory count distinct actors for legacy pages without per-actor tracking?

Pages created before the breadth table was introduced report `distinct_actors` as `0` or `1`. The `.max(1)` call in the formula ensures these pages receive neutral treatment (`breadth = 1.0`) rather than penalties. You can backfill actor counts via the aggregation pipeline in [`reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/reader.rs) if desired.

### What `breadth_weight` value strikes a good balance between individual and team utility?

The [[`access_breadth.rs`](https://github.com/akitaonrails/ai-memory/blob/main/access_breadth.rs)](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/tests/suite/access_breadth.rs) test suite suggests evaluating `1.0` to `2.0` for moderate team preferencing. At `1.0`, 10 distinct actors yield a `3.3x` access-term multiplier—strong enough to matter without dominating age-based decay. Profile your specific access patterns before settling on a production value.