@dpid/associative-ai
v0.1.0
Published
LLM-free associative learning (Rescorla-Wagner + eligibility traces) for needs-based AI agents
Maintainers
Readme
@dpid/associative-ai
LLM-free associative learning (Rescorla-Wagner + eligibility traces) for @dpid/needs-based-ai agents. It turns perceived stimuli and need-satisfaction edges into an emergent acquisition/extinction curve — the classic Pavlov's-dog experiment: an agent that hears a bell before every meal starts salivating at the bell alone, and stops if the bell keeps ringing with no food behind it. Needs stay ground truth: the associator only ever produces a raw prediction strength V; a sated dog doesn't salivate at the bell because the consumer gates the response on hunger, not because the association weakened.
Install
npm install @dpid/associative-ai @dpid/needs-based-aiThere are no runtime dependencies. @dpid/needs-based-ai ^0.3.0 is the sole peer dependency (not optional — the event emitter and type surface come from it). @dpid/command-state-machine is a dev-only dependency used to drive the example's tick loop; it is not required to use the library. This is the LLM-free member of the -ai family: no @anthropic-ai/sdk, no web-llm, nothing that talks to a model.
The learning rule
Every reinforcement trial applies the Rescorla-Wagner update to each currently eligible conditioned stimulus (CS):
dV = alpha * beta * (lambda - sumV)alpha is the CS's salience/learning rate, beta is the unconditioned stimulus (US)'s associability, and lambda is the asymptote (US intensity). The critical detail is sumV: it is the sum of V across every CS present in the trial, read once before any weight is written, and every present CS is updated against that same shared error term. This shared read is what makes blocking and overshadowing emergent — a CS that arrives after another cue has already saturated sumV toward lambda gets almost no error term left to learn from, with no special-cased "blocking" logic anywhere in the code.
The second load-bearing rule is contiguity: a CS's eligibility trace must age at least one tick (ageSeconds > 0) before it counts as "present" for a reinforcement trial. This makes learning forward-only with no explicit direction guard — perceiving a stimulus in the same tick as the outcome, or after it, does not learn; only a stimulus perceived strictly before the outcome (and still within traceWindowSeconds) does.
Usage
import { Associator } from '@dpid/associative-ai';
const assoc = Associator.create({ alpha: 0.3, beta: 0.5, lambda: 1 });
assoc.perceive('bell'); // refresh the bell's eligibility trace
assoc.update(dt); // age traces by dt; the bell trace is now eligible
assoc.reinforce('food'); // one learning trial over every live, eligible CS
assoc.strengthOf('bell', 'food'); // read V(bell -> food)Wiring the two integration seams
- Perceive from the needs-based-ai perception seam, not from game logic: subscribe to
agent.onAdvertisementReceivedand callassociator.perceive(trait.id)for each trait on the advertisement. This is the same event surface a vanilla needs-based agent already receives, so no extra sensing code is needed. - Reinforce on the need-satisfaction edge, never per-tick. A multi-tick "eat" action should call
reinforce('food')once, at the moment satisfaction happens — not once per frame while the action is in progress.reinforce()is intentionally trial-triggered, not time-triggered. - Express the conditioned response by listening to
onConditionedResponseand gating it on the agent's live motivational state:magnitude = prediction * g(hunger), wheregis whatever hunger-to-gain mapping the game defines. The associator never reads hunger itself — that gate belongs entirely to the consumer, which is why a sated agent can have a highstrengthOf('bell', 'food')and still not react. - Approach behavior (the agent choosing to move toward the bell) is not part of this module either. Drive it through needs-based-ai's own ranking hooks: broadcast a phantom
Advertisementfor the CS, or scale acustomRankingFunction's weight bystrengthOf(csId, usId), so a strongly conditioned cue competes for attention the same way a real need-satisfying object would.
Config
Associator.create(config?: AssociatorConfig) — every field is optional.
| Field | Meaning | Default |
| --- | --- | --- |
| alpha | CS salience / learning rate. A single number applies to every CS, or pass a Record<string, number> to give specific stimuli their own salience. | 0.3 |
| beta | US associability — how strongly a given unconditioned stimulus drives learning. | 0.5 |
| lambda | Asymptote that V approaches; conventionally the US's intensity. | 1 |
| traceWindowSeconds | How long a perceived stimulus stays eligible for reinforcement after perceive(). | 2 |
| traceDecayPerSecond | Exponential decay rate of a trace's eligibility while it's within the window. | 1 |
| responseThreshold | The V value at/above which onConditionedResponse fires. | 0.5 |
| extinguishOnUnpairedCs | If true, a CS trace that expires without ever being reinforced runs one lambda = 0 trial — this is what drives extinction when a CS is presented alone. | true |
Example
npm run example:dog-bellRuns the headless Pavlov's-dog demo offline (examples/dog-bell/main.ts, executed via tsx, no network): 30 forward bell-then-food pairings followed by 15 bell-alone rounds, printing V(bell -> food) as both a number and an ASCII bar each trial. The tick loop is driven through @dpid/command-state-machine — a CommandableState runs a command whose onUpdate(dt) calls assoc.update(dt), and the example advances the world with stateMachine.update(dt) rather than calling update() directly, mirroring how a game would drive it. Trimmed sample output:
ACQUISITION -- forward pairings (perceive bell, age one tick, then reinforce food)
acq 1/30 V=0.150 [######..............|...................]
acq 5/30 V=0.556 [######################..................]
🔔→🤤 salivation (prediction=0.56)
the bell alone now predicts food -- the reflex has emerged mid-acquisition
acq 30/30 V=0.992 [########################################]
EXTINCTION -- bell presented alone, no reinforcement
ext 1/15 V=0.844 [##################################......]
ext 15/15 V=0.087 [###.................|...................]
SUMMARY
peak V during acquisition: 0.992
final V after extinction: 0.087 (below threshold 0.5)Scripts
npm test # Run tests with vitest
npm run typecheck # Type check the library and the example with tsc
npm run build # Build with tsup (ESM + CJS + d.ts)
npm run example:dog-bell # Run the offline dog-bell demo via tsxDesign note
This module is drive-agnostic on purpose. It has no concept of hunger, sleep, or any other need — it only tracks CS -> US prediction strength. A conditioned response is association x motivational state; the associator supplies the association, and the game (or @dpid/needs-based-ai, via the seams above) owns the motivational gate.
