All Articles

Replicant Narrative: Modelling Character Memory with Cognitive Science

While building the showcase app for the ReplicantCore platform, I kept adding features around story flow, character behaviour, and narrative-driven interactions. What started as a few helper functions quickly grew into something far more complex - complex enough that it deserved its own dedicated module.

That’s how ReplicantNarrative joined the suite of modules that make up ReplicantCore.

flowchart TD
    subgraph Row1[ ]
        direction LR
        DASH[Dashboard]
        APPS[Apps]
    end

    DASH --> GATEWAY["⠀⠀⠀⠀⠀⠀Gateway⠀⠀⠀⠀⠀⠀"]
    APPS --> GATEWAY

    subgraph Services[ ]
        direction LR
        A[ODE Auth]
        B[ReplicantCore]
        C[ReplicantResonance]
        D[ReplicantGuard]
        E[ReplicantNarrative]
    end

    GATEWAY --> A
    GATEWAY --> B
    GATEWAY --> C
    GATEWAY --> D
    GATEWAY --> E

    %% --- Highlighting Styles ---
    classDef highlight fill:#ffd966,stroke:#b8860b,stroke-width:2px,color:#000;
    classDef faded fill:#e0e0e0,stroke:#999,color:#666;

    %% --- Apply classes ---
    class E highlight;
    %% --- class DASH,APPS,GATEWAY,A,C,D faded;

ReplicantNarrative brings together a set of functions inspired by cognitive science, and in this article I’m focusing specifically on the memory processor - the part that scores and ranks a character’s memories to decide which ones might surface during a quest in a video game, a conversation, or a moment in a story.

In the early prototype of the showcase app, I built a very simple memory system - just a list of “important things that happened” to a character, of which a random selection as made and then used as part of the processing. The real challenge came later - memory recall. Some memories were resurfacing far too often, while others kept repeating to the point of feeling awkward. Imagine a friend saying, “Hey, remember that time last year when it was really hot?” every five minutes in the same conversation.

I needed a way to track the the memories appearing in the given situation, and make recall and what is said feel natural.

That led me into cognitive science. I dug into how human memory actually works and ended up exploring two major ideas - ACT‑R and emotional memory consolidation. In ReplicantNarrative, I combine these into a single activation score that determines which memories feel “present” to a character at any given moment.

The goal is to avoid a flat memory system where every stored fact is equally available. Real memory doesn’t behave like that. Some things fade. Some things stick because they were emotionally charged. Some get crowded out by similar experiences. The memory processor tries to model all three.

Ill try and explain how all the components work, then end with a notebook showing a worked example.


The Activation Formula

Every memory receives an activation score built from four components. I kept this similar to ReplicantCore’s other modules, in that it’s deterministically built up in steps to give an overall result.

A = base_level + emotional_salience - fan_penalty + novelty_bonus

This time, each component is grounded in a specific research finding.

Then, the actual activation doesn’t determine retrieval directly - it feeds into a probability function that decides whether a memory surfaces at all.


Component 1: Base-Level Learning (Decay)

Bi=ln ⁣(j=0ntj0.5)B_i = \ln\!\left(\sum_{j=0}^{n} t_j^{-0.5}\right)

This is the ACT‑R (Adaptive Control of Thought – Rational) base‑level learning equation (Anderson & Schooler, 1991).

It models how human memories naturally strengthen and fade over time.

At its core, the equation captures two things at once:

  • Recency - memories used recently are easier to recall
  • Frequency - memories used often are easier to recall

A memory that’s been retrieved many times builds up higher activation, while a memory used only once a long time ago gradually fades toward the retrieval threshold. The decay exponent of 0.5 reflects empirical findings about how quickly human recall drops off with time.

A helpful way to picture this is to imagine that every memory has a tiny light inside your brain. Every time you use that memory, the light brightens. When you don’t use it, the light slowly dims. ACT‑R’s base‑level equation is simply a way of measuring how bright that light is right now, based on how many times you used the memory and how long ago each use was.

In this module, time is measured in days rather than seconds. Because the system operates on narrative timescales - months and years of a character’s life - using seconds would make every memory appear extremely old and effectively “dark,” breaking the model.

To put that scary looking equation simply:

  • Each past use of a memory gives it a little boost.
  • Recent boosts are strong, old boosts are weak.
  • Add them all up, then squash the result with a log.
  • That final number is how “alive” the memory feels right now.

Component 2: Emotional Salience

Not all memories are created equal. Some moments burn themselves into your mind, while others fade almost immediately. To model this, the system adds an emotional salience bonus using:

βi=arousal(emotion)×intensity\beta_i = \text{arousal}(\text{emotion}) \times \text{intensity}

This captures a well‑established finding in cognitive neuroscience: emotionally charged events are remembered more strongly. The amygdala boosts hippocampal consolidation during high‑arousal states, making those memories more durable (Cahill & McGaugh, 1995).

To quantify this, the model uses wheel of emotions (Plutchik 1980), assigning each emotion label an “arousal weight” - essentially, how activating that emotion is:

Emotion Arousal weight
Fear 1.8
Anger 1.6
Surprise 1.5
Sadness 1.3
Disgust 1.2
Joy 1.1
Trust 1.0
Anticipation 1.0
Neutral 0.8

The second part of the formula, intensity, is a value from 0.0–1.0 describing how strongly the character experienced that emotion in the moment.

Think of emotional salience as a multiplier for how “sticky” a memory becomes.

  • The emotion type determines the kind of boost
  • The intensity determines how much of that boost is applied

So:

  • A high‑intensity fearful event (intensity = 1.0) → 1.8×1.0=1.8 → a big bonus to memory strength
  • A low‑intensity joyful moment (intensity = 0.3) → 1.1×0.3=0.33 → a small bonus

This mirrors real life - A terrifying experience or a deeply joyful moment stays with you for years, while a mildly pleasant afternoon fades quickly, and this is what that equation tries to compute.


Component 3: The Fan Effect (Interference)

fani=γln(ntheme)if ntheme>1, else 0\text{fan}_i = \gamma \cdot \ln(n_{\text{theme}}) \quad \text{if } n_{\text{theme}} > 1, \text{ else } 0

Human memory isn’t just about strengthening; it’s also about competition. Anderson’s fan effect (1974) describes a simple but powerful idea:

The more things you associate with a concept, the harder it becomes to retrieve any one of them.

If a character has only one memory tagged "family", that memory has the theme all to itself. But if they have five different "family" memories, each one competes for attention. The system models this by applying a small penalty whenever more than one memory shares the same theme tag.

The penalty grows with the number of memories, but only logarithmically. That means it increases quickly at first, then levels off. The scaling factor γ=0.4 keeps the effect noticeable but not overwhelming:

  • 2 memories on a theme → tiny penalty
  • 5 memories → moderate penalty
  • 12 memories → activation reduced by about 1.0, enough to matter but not enough to erase the memory

This mirrors real cognition: A concept with many competing associations becomes “crowded,” making any single memory slightly harder to surface - but not impossible.


Component 4: Novelty Bonus

noveltyi={1.2if never retrieved in a story0otherwise\text{novelty}_i = \begin{cases} 1.2 & \text{if never retrieved in a story} \\ 0 & \text{otherwise} \end{cases}

Brand‑new memories behave differently from old ones. In human cognition, recent experiences have a natural advantage-they’re vivid, accessible, and not yet tangled up with other associations. This echoes classic findings like Murdock’s (1962) recency effect, where the most recently encountered items are recalled more easily.

To capture this, the model gives any memory that has never been used in a story a flat bonus of 1.2. This ensures that fresh memories aren’t immediately overshadowed by older, frequently rehearsed ones. It also encourages the narrative engine to surface newly formed experiences, making characters feel more present and reactive to what just happened.

Once a memory is retrieved for the first time, the novelty bonus disappears. At that point, it behaves like any other memory in the system-its strength is determined by recency, frequency, emotional salience, and interference.

This small rule helps the system avoid a common pitfall in AI memory models:

new memories getting buried before they ever have a chance to matter.


Retrieval Probability

Once a memory’s activation is computed, the system converts that activation into an actual probability of recall using a logistic function:

P(retrieveAi)=11+e(τAi)/sP(\text{retrieve} \mid A_i) = \frac{1}{1 + e^{(\tau - A_i)\,/\,s}}

This is the same kind of curve used in cognitive models, neural firing thresholds, and even decision‑making models in psychology. It takes a raw activation value and turns it into something intuitive: how likely is this memory to surface right now?

What the logistic function means (in human terms)

  • Memories far below the threshold (Ai≪τ) → probability drops toward 0 → the memory is effectively inaccessible
  • Memories far above the threshold (Ai≫τ) → probability rises toward 1 → the memory is almost guaranteed to surface
  • Memories near the threshold → probability changes smoothly, not abruptly → small differences in activation matter most here

The logistic curve avoids a hard cutoff. Instead of saying “this memory is either available or not,” it models recall as a continuous, graded process-exactly how human memory behaves. Sometimes a memory is right on the edge: you might remember it, or you might not. The logistic captures that uncertainty.

Why this matters for narrative behaviour

This final step turns the activation score into something the story engine can use:

  • High‑activation memories reliably come to mind
  • Low‑activation memories stay dormant
  • Mid‑range memories create natural variability and surprise

Characters don’t behave deterministically-they recall things with the same kind of fuzzy, probabilistic pattern humans do. A memory that “should” come up usually does, but not always. And a memory that’s nearly forgotten might still surface at just the right moment.


Memory Lifecycle

Memories in the system evolve over time. They don’t all behave the same way forever-just like in human cognition, their role changes depending on how often they’re recalled. Each memory moves through three stages:

Fresh

A newly created memory. It receives the novelty bonus, giving it a temporary boost so it isn’t immediately overshadowed by older, well‑rehearsed memories. After the first time it’s retrieved in a story, it transitions to the next stage.

Episodic

The standard long‑term state. Most memories spend their lives here. They’re scored using the full activation model-recency, frequency, emotional salience, interference-and are sampled probabilistically during story generation. This mirrors human episodic memory: specific events that can be recalled when relevant.

Trait

A memory retrieved in seven or more stories “graduates” into a trait. Traits are no longer sampled; they’re always injected into the story context as stable descriptors of the character. This reflects Tulving’s (1972) episodic‑to‑semantic consolidation: when an event is rehearsed enough times, it stops being a memory of something that happened and becomes part of the person’s identity.


The Scoring Endpoint

The scoring endpoint takes a character’s entire memory set and evaluates each memory using the full activation model. It returns three outputs that the narrative engine can use immediately:

  • traits - Memories that have “graduated” into stable character attributes. These are always included in story context because they represent who the character is, not just what they remember.
  • selected_episodic - The top N episodic memories whose activation sits above the retrieval threshold. These are the memories that feel “alive” right now-recent, emotionally charged, or frequently rehearsed enough to surface naturally in the next story beat.
  • all_scores - A complete diagnostic breakdown for every memory. This includes the final activation value and each component (base‑level, emotional salience, fan effect, novelty). It’s useful for debugging, tuning, or visualising a character’s internal memory landscape.

A separate update endpoint records when a memory is actually used in a generated story. It appends a new retrieval timestamp and checks whether the memory has crossed the trait graduation threshold. If it has, the memory transitions from episodic to trait, ensuring future stories treat it as part of the character’s identity rather than a recallable event.

This separation - scoring vs. updating - keeps the system clean: one endpoint evaluates the present moment, the other evolves the character over time.


Worked Example

Lets take a look at an example of how the ReplicantNarrative service processes all of this

Lets set up a character called Sam, who is 12 years old and has 5 memories

sam_memories = [
    MemoryInput(
        id="a",
        text="Broke his arm falling off a roof trying to rescue a kite.",
        emotion="fear", intensity=0.8, theme="recklessness", stage="episodic",
        created_at=days_ago(400),
        retrieval_timestamps=[days_ago(300), days_ago(200), days_ago(100),
                               days_ago(50), days_ago(20), days_ago(7), days_ago(2)],
    ),
    MemoryInput(
        id="b",
        text="Won the regional science fair with a home-made seismograph.",
        emotion="joy", intensity=0.9, theme=None, stage="episodic",
        created_at=days_ago(60),
        retrieval_timestamps=[days_ago(30)],
    ),
    MemoryInput(
        id="c",
        text="Lied to his mum about where he was going and felt guilty all week.",
        emotion="sadness", intensity=0.6, theme=None, stage="fresh",
        created_at=days_ago(2),
        retrieval_timestamps=[],
    ),
    MemoryInput(
        id="d",
        text="Learned to bake sourdough with his dad during a rainy half-term.",
        emotion="joy", intensity=0.4, theme=None, stage="episodic",
        created_at=days_ago(500),
        retrieval_timestamps=[],
    ),
    MemoryInput(
        id="e",
        text="Fell out with his best friend over football and didn't apologise for a month.",
        emotion="anger", intensity=0.7, theme="recklessness", stage="episodic",
        created_at=days_ago(180),
        retrieval_timestamps=[days_ago(90)],
    ),
]

# Lookup map so we can print the memory text from the scored result (ScoredMemory only has id)
mem_text = {m.id: m.text for m in sam_memories}

# ── Call score_memories directly ──────────────────────────────────────────────
result = score_memories(MemoryScoreRequest(memories=sam_memories, max_episodic=3))

# ── Plain-English output ───────────────────────────────────────────────────────
WHY = {
    "a": "7 retrievals - hits the graduation threshold → permanent trait.",
    "b": "Recent (60d), used once, high joy (β=1.35) → strong base-level.",
    "c": "Brand new, never used → novelty bonus +1.2 lifts it above threshold.",
    "d": "500 days old, never used → novelty bonus is its only lifeline.",
    "e": "Shares 'recklessness' theme with 'a' → fan penalty knocks it down.",
}

if result.traits:
    print("TRAIT - always injected (permanent character identity):")
    for t in result.traits:
        print(f"  [{t.id}] {mem_text[t.id]}")
        print(f"       {WHY[t.id]}")
    print()

print("SELECTED EPISODIC - top 3 injected as active memories:")
for e in result.selected_episodic:
    print(f"  [{e.id}] {mem_text[e.id]}")
    print(f"       activation={e.activation:.3f}  P(retrieve)={e.retrieval_probability:.3f}")
    print(f"       {WHY[e.id]}")
    print()

selected_ids = {t.id for t in result.traits} | {e.id for e in result.selected_episodic}
skipped = [m for m in sam_memories if m.id not in selected_ids]
if skipped:
    print("NOT SELECTED:")
    for m in skipped:
        print(f"  [{m.id}] {m.text}")
        print(f"       {WHY[m.id]}")

And this produces this result

SELECTED EPISODIC - top 3 injected as active memories:
  [a] Broke his arm falling off a roof trying to rescue a kite.
       activation=1.643  P(retrieve)=0.998
       7 retrievals - hits the graduation threshold → permanent trait.

  [c] Lied to his mum about where he was going and felt guilty all week.
       activation=1.435  P(retrieve)=0.997
       Brand new, never used → novelty bonus +1.2 lifts it above threshold.

  [b] Won the regional science fair with a home-made seismograph.
       activation=0.176  P(retrieve)=0.966
       Recent (60d), used once, high joy (β=1.35) → strong base-level.

NOT SELECTED:
  [d] Learned to bake sourdough with his dad during a rainy half-term.
       500 days old, never used → novelty bonus is its only lifeline.
  [e] Fell out with his best friend over football and didn't apologise for a month.
       Shares 'recklessness' theme with 'a' → fan penalty knocks it down.

he results are immediately interpretable. Memory [a] - the broken arm - is the clear winner, with an activation of 1.643 and a retrieval probability of 0.998. That makes sense: fear has the highest arousal weight in the model (1.8), and at intensity 0.8 it contributes 1.44 before base‑level learning is even considered. On top of that, the memory has been retrieved seven times, each retrieval adding another term to the decay sum and building a strong activation floor that resists forgetting. It has also crossed the trait graduation threshold, meaning the next call to POST /memory/update will promote it from an episodic event to a stable character trait. It stops being “he once broke his arm” and becomes “he has always been reckless with his own safety.”

Memory [c] - lying to his mum - ranks second for a very different reason. It was encoded only two days ago and has never appeared in a story, so its base‑level is low. What lifts it is the novelty bonus: a flat +1.2 applied to any memory that has never been retrieved. This ensures new context has a chance to surface instead of being drowned out by older, well‑rehearsed memories. Once this memory appears in a story, the bonus disappears and it must compete on recency, frequency, and emotion like everything else.

Memory [b] - the science fair win - earns third place through straightforward recency. It’s only 60 days old, with a prior retrieval 30 days ago, which keeps its base‑level healthy. Joy at intensity 0.9 adds a salience boost of 1.35, and the absence of a theme tag means no fan penalty. Its activation of 0.176 is modest compared to the top two, but still comfortably above the −1.5 retrieval threshold, giving it a retrieval probability of 0.966.

The two excluded memories illustrate different failure modes:

  • Memory [d] - sourdough with dad - is simply too old. Encoded 500 days ago and never retrieved, its base‑level has decayed to around −3.1. The novelty bonus (+1.2) helps, but not enough: it lands at −1.6, just below the cutoff. A single retrieval would reinforce it and bring it back into range.
  • Memory [e] - the falling out over football - is limited by the fan effect. It shares the recklessness theme with memory [a], and with two memories in that fan, the model applies a penalty of 0.4×ln⁡(2)≈0.28 to each. That small interference is enough to push [e] out of the top three. This isn’t a punishment-it’s the same cognitive interference that makes it harder to recall one friend’s phone number when you’ve memorised several. The memories compete for the same associative cue.

Limitations

Theme tags are flat - interference is calculated across all memories sharing the same string tag. There’s no semantic grouping; "family" and "home" are treated as entirely separate themes even if they’re conceptually related.

No forgetting floor - activation can decay arbitrarily low, but memories are never deleted by the model. A memory that hasn’t been retrieved in years will sit below threshold indefinitely. Whether to prune these is left to the application layer.

Emotion is fixed at creation - the emotion label is set when a memory is created and doesn’t change. A memory tagged "joy" that later becomes a source of grief doesn’t update its arousal weight.

Graduation is retrieval-count only - the trait threshold is a fixed count of seven retrievals regardless of how spread out they were in time or how emotionally significant each use was. A more sophisticated model would weight retrievals by recency and intensity.


Next Steps

The memory processor is one module of the broader Replicant Narrative service, which also handles character detection and dialogue attribution from media inputs. In a future article I will cover how the systems connect using detected characters and their passage level speech to populate and update memory records over the course of a generated piece of media.

Published Apr 22, 2026

Software engineer and technical founder in London, focused on building practical products in AI and hardware.