Documentation/Memory Predicate Library — Usage Guide
v2.0.0-beta.1Snapshot bebdcd1Markdown ↗

Memory Predicate Library — Usage Guide

The memory layer ships with a pluggable predicate registry that controls the vocabulary used for facts. Without a registry, predicates are free-form strings. With one, you get canonicalization, sensible defaults, auto-supersession for single-valued predicates, ranking weights, and an LLM prompt that teaches your vocabulary to the model.

This guide covers the recipes. For the API reference, see MEMORY_API.md § Predicate Registry.


Table of Contents

  1. When to use a registry
  2. Three setup patterns
  3. The standard library
  4. Extending the standard library
  5. Building a custom vocabulary
  6. Canonicalization — what it does
  7. singleValued auto-supersession
  8. isAggregate counters
  9. Feeding the vocabulary to the LLM
  10. Drift monitoring — newPredicates
  11. Strict mode
  12. Ranking weights
  13. Recipes
  14. Migration & gotchas

When to use a registry

Use a registry when any of these matter:

  • LLM vocabulary drift. Without one, the model will emit worksAt, works_at, works-at, employed_by, works_for for the same concept across different extractions. Ranking, aggregation, and querying won't unify them.
  • Single-valued attributes. Facts like current_title or current_role should supersede on new writes, not accumulate. The registry handles this automatically.
  • Importance defaults. works_at is identity-level (importance 1.0); mentioned is ephemeral (0.3). Centralizing these defaults avoids every caller passing importance manually.
  • Ranking weights. Some predicates should outrank others in top-facts ranking regardless of recency. The registry lets you set these once.
  • LLM prompt hygiene. Dumping a list of canonical names into the extraction prompt dramatically improves model consistency.

If none of these apply, skip the registry — the memory layer works fine without one.


Three setup patterns

Pattern 1 — use the standard library as-is

ts
import { MemorySystem, PredicateRegistry, InMemoryAdapter } from 'oneringai';

const memory = new MemorySystem({
  store: new InMemoryAdapter(),
  predicates: PredicateRegistry.standard(),
});

44 predicates across 10 categories. Good starting point for general knowledge-graph use cases (people, orgs, tasks, communications).

Pattern 2 — extend the standard library with your domain

ts
const registry = PredicateRegistry.standard();
registry.register({
  name: 'invested_in',
  description: 'Investor relationship between person/org and company.',
  category: 'financial',
  subjectTypes: ['person', 'organization'],
  objectTypes: ['organization'],
  defaultImportance: 0.9,
  rankingWeight: 1.3,
  aliases: ['investor_of', 'funded'],
});
registry.register({
  name: 'board_member_of',
  description: 'Person sits on an organization\'s board.',
  category: 'financial',
  subjectTypes: ['person'],
  objectTypes: ['organization'],
  inverse: 'has_board_member',
  defaultImportance: 1.0,
  rankingWeight: 1.4,
});

const memory = new MemorySystem({ store, predicates: registry });

Pattern 3 — fully custom vocabulary + strict mode

ts
const clinical = PredicateRegistry.empty().registerAll([
  { name: 'patient_of', description: 'Patient-doctor relation.', category: 'clinical' },
  { name: 'prescribed', description: 'Prescription event.', category: 'clinical', defaultImportance: 0.8 },
  { name: 'diagnosed_with', description: 'Clinical diagnosis.', category: 'clinical', defaultImportance: 1.0 },
  { name: 'admitted_to', description: 'Hospital admission.', category: 'clinical' },
  { name: 'discharged_from', description: 'Discharge event.', category: 'clinical' },
]);

const memory = new MemorySystem({
  store,
  predicates: clinical,
  predicateMode: 'strict', // reject anything outside this vocabulary
});

The standard library

ts
PredicateRegistry.standard()

Returns a fresh instance (safe to mutate). 44 predicates in 10 categories:

Category Predicates
identity works_at, reports_to, current_title, current_role, located_in, is_member_of, founded
organizational part_of, subsidiary_of, manages, owns, acquired, merged_with
task committed_to, blocked_by, depends_on, prepares_for
decision decision_made
communication emailed, called, messaged, met_with, mentioned, cc_ed, responded_to, interaction_count (aggregate)
observation observed_topic, expressed_concern, expressed_interest, acknowledged, noted
event attended, hosted
priority tracks_priority, priority_affects
document profile, biography, memo, meeting_notes, research_note, has_document
social knows, works_with, colleague_of

Special predicates with auto-behavior:

  • singleValued (writes supersede prior): current_title, current_role.
  • isAggregate (updates in place): interaction_count.
  • profile: consumed by MemorySystem.getContext as the canonical per-entity profile. Don't rename.

Single-entity attributes are metadata, not facts. Task state lives on task.metadata.state (set at creation, transitioned via MemorySystem.transitionTaskState). Same for dueAt, priority, completedAt, createdBy, createdAt—and for event/task time on metadata.startTime/endTime/scheduledAt. No state_changed / completed / created / reviewed / has_due_date / has_priority / occurred_on / scheduled_for / started_on / ended_on / cancelled_due_to predicates exist. Inter-entity relationships such as prepares_for, blocked_by, and depends_on remain facts. committed_to is retained for backward-compatible queries but excluded from the default extraction prompt because a task entity now carries assignment and evidence itself. See transitioning task state.


Extending the standard library

Start from standard, register your domain predicates:

ts
const r = PredicateRegistry.standard();
r.register({ name: 'investor_in', description: '…', category: 'financial', rankingWeight: 1.3 });
r.register({ name: 'customer_of', description: '…', category: 'commercial', rankingWeight: 1.2 });

Override a standard predicate by unregistering + re-registering:

ts
r.unregister('works_at');
r.register({
  name: 'works_at',
  description: 'Employment — customized for your domain.',
  category: 'identity',
  aliases: ['worksAt', 'employed_by', 'works_for'], // add the extra form your LLM produces
  defaultImportance: 1.0,
  rankingWeight: 2.0, // bump it up
});

Building a custom vocabulary

For domains where the standard library is noise (legal, clinical, bioinformatics, etc.), start from empty() and define only what you need:

ts
const legal = PredicateRegistry.empty().registerAll([
  { name: 'represents', description: 'Attorney-client representation.', category: 'legal' },
  { name: 'filed', description: 'Legal filing action.', category: 'legal' },
  { name: 'settled_with', description: 'Settlement agreement.', category: 'legal', defaultImportance: 0.9 },
  { name: 'plaintiff_in', description: 'Plaintiff role in case.', category: 'legal' },
  { name: 'defendant_in', description: 'Defendant role in case.', category: 'legal' },
]);

Combine with predicateMode: 'strict' to prevent the LLM from inventing general-purpose predicates outside your domain.


Canonicalization — what it does

When a registry is attached, addFact normalizes the predicate before storage:

Input Stored as
works_at works_at (unchanged)
worksAt works_at (camelCase → snake)
works-at works_at (dash → snake)
Works At works_at (whitespace → snake)
WORKS_AT works_at (lowercased)
employed_by works_at (alias lookup)
unknown_thing unknown_thing (unknown passes through)

All downstream reads, ranking, and aggregation see the canonical form.


singleValued auto-supersession

A singleValued: true predicate means "the subject has exactly one current value for this predicate." When you write a new value, the prior fact is automatically archived and the new one supersedes it.

ts
const alice = await memory.upsertEntity({
  type: 'person',
  displayName: 'Alice',
  identifiers: [{ kind: 'email', value: 'a@a.com' }],
}, {});

// First write
const first = await memory.addFact(
  { subjectId: alice.entity.id, predicate: 'current_title', kind: 'atomic', value: 'Engineer' },
  {},
);

// Second write — first is auto-archived; second has supersedes=first.id
const second = await memory.addFact(
  { subjectId: alice.entity.id, predicate: 'current_title', kind: 'atomic', value: 'Senior Engineer' },
  {},
);
// second.supersedes === first.id
// first is archived; queries return only second.

Disable auto-supersession globally

ts
new MemorySystem({
  store,
  predicates: registry,
  predicateAutoSupersede: false, // append-only semantics for all singleValued predicates
});

Scope isolation

Auto-supersession is scope-bounded. A group-scoped write can't archive a user-scoped prior (and vice versa). This means current_title may have multiple "current" values across scopes — intentional, not a bug.

ts
// User A sets current_title
await memory.addFact({ subjectId: alice.id, predicate: 'current_title', kind: 'atomic', value: 'Engineer', ownerId: 'userA' }, { userId: 'userA' });

// User B sets current_title — does NOT supersede A's (different scope)
await memory.addFact({ subjectId: alice.id, predicate: 'current_title', kind: 'atomic', value: 'Manager', ownerId: 'userB' }, { userId: 'userB' });

// A still sees "Engineer". B sees "Manager". Both coexist.

isAggregate counters

An isAggregate predicate is meant to update in place rather than supersede. The standard library has interaction_count. You can treat it as a rolling counter:

ts
await memory.addFact(
  {
    subjectId: user.id,
    predicate: 'interaction_count',
    kind: 'atomic',
    objectId: customer.id,
    value: 1,
  },
  {},
);

Note: the memory layer does not implement counter semantics itself — you increment the value. The isAggregate flag documents intent and ensures the predicate doesn't accidentally get marked singleValued (which would auto-archive the prior). Callers integrating with aggregation pipelines can inspect fact.isAggregate and aggregate appropriately.


Feeding the vocabulary to the LLM

Pass the registry into the extraction prompt so the model learns the canonical names:

ts
import { defaultExtractionPrompt } from 'oneringai';

const prompt = defaultExtractionPrompt({
  signalText: emailBody,
  signalSourceDescription: 'email from alice@acme.com',
  predicateRegistry: registry,
  maxPredicatesPerCategory: 5, // cap to keep prompt token budget in check (default 5)
});

The rendered block looks like:

text
## Predicate vocabulary
Use these predicate names where applicable. If none fits, invent a snake_case one.

### identity
- `works_at` — Person-to-organization employment relationship. (inverse: `employs`) (aliases: `worksAt`, `employed_by`, `employee_of`)
  e.g. (John, works_at, Acme)
- `reports_to` — Management chain — subject reports to object. (inverse: `manages`)
  e.g. (John, reports_to, Jane)
…

The LLM can still invent predicates outside the vocabulary — see drift monitoring.

Narrow the prompt to relevant categories

For a support-ticket signal, you probably don't need organizational predicates. Filter:

ts
const prompt = defaultExtractionPrompt({
  signalText,
  predicateRegistry: registry,
  // Only include the categories the LLM needs for this extraction
  maxPredicatesPerCategory: 8,
  // Use registry.renderForPrompt directly if you want finer control:
  // predicateRegistry renders inside the prompt automatically,
  // or pre-render yourself and swap in a custom prompt template.
});

For advanced prompt shaping, render directly:

ts
const block = registry.renderForPrompt({
  categories: ['task', 'communication', 'state'],
  subjectType: 'person',
  maxPerCategory: 10,
});
// inject `block` into your custom prompt

Drift monitoring — newPredicates

When the LLM emits a predicate not in your registry, it's canonicalized and written anyway (permissive mode). The extraction result surfaces these for review:

ts
const result = await resolver.resolveAndIngest(llmOutput, 'signal_123', scope);

if (result.newPredicates.length > 0) {
  // The LLM invented these. Options:
  //   - Promote frequent ones into the registry.
  //   - Tighten the prompt with better examples / explicit warnings.
  //   - Accept them as long-tail (they still get canonicalized + stored).
  await logToDashboard({ driftPredicates: result.newPredicates, signalId: 'signal_123' });
}

newPredicates is always deduplicated by canonical form and empty when no registry is configured.


Strict mode

Reject any fact whose canonicalized predicate is not in the registry:

ts
new MemorySystem({
  store,
  predicates: registry,
  predicateMode: 'strict',
});

// This throws:
await memory.addFact({ subjectId, predicate: 'mystery_thing', kind: 'atomic', value: 'x' }, {});
// Error: addFact: predicate 'mystery_thing' (canonical: 'mystery_thing') not in registry.

Through ExtractionResolver, rejections land in result.unresolved (via the existing try/catch) AND result.newPredicates:

ts
const result = await resolver.resolveAndIngest(llmOutput, 'signal_123', scope);
// result.unresolved[i] — has reason "not in registry"
// result.newPredicates — same predicates listed for drift tracking

Strict mode requires a registry. Setting predicateMode: 'strict' without a predicates registry throws at MemorySystem construction.

When to use strict:

  • Closed domain (clinical, legal) where unknown predicates indicate a prompt bug.
  • Production with a well-tuned vocabulary and low drift tolerance.

When to stay permissive:

  • Exploratory / general knowledge graph.
  • Active vocabulary iteration (let the LLM suggest predicates, promote frequent ones).

Ranking weights

Registry rankingWeight values feed into RankingConfig.predicateWeights. Higher weight → higher score in topFacts ranking. User-supplied weights always win on collision.

ts
// Registry sets works_at → 1.5
// User overrides works_at → 3.0

const memory = new MemorySystem({
  store,
  predicates: PredicateRegistry.standard(),
  topFactsRanking: {
    predicateWeights: { works_at: 3.0 }, // wins over registry's 1.5
  },
});

The ranking formula: score = confidence × recency × predicateWeight × importanceMultiplier. See MEMORY_API.md § Ranking.


Recipes

R1 — Inspect what's in the registry

ts
registry.list();                             // all definitions
registry.list({ categories: ['task'] });     // filter by category
registry.list({ subjectType: 'person' });    // filter by subject-type hint
registry.categories();                       // list of all categories
registry.get('worksAt');                     // lookup (accepts any form)
registry.has('employed_by');                 // boolean

R2 — Canonicalize a predicate outside addFact

Useful when you have a predicate string from user input and want to canonicalize before a query:

ts
const canonical = memory.canonicalizePredicate('worksAt'); // → 'works_at'
const facts = await memory.findFacts({ predicate: canonical, subjectId }, ...);

Also:

ts
memory.hasPredicateRegistry();       // boolean
memory.getPredicateDefinition('works_at'); // full PredicateDefinition | null

R3 — Register a batch of domain predicates from a config file

ts
import myPredicates from './my-vocabulary.json';

const r = PredicateRegistry.standard();
r.registerAll(myPredicates);

R4 — Export the registry for documentation

ts
// Print a markdown-formatted vocabulary reference for your docs:
console.log(registry.renderForPrompt({ maxPerCategory: 999 }));

R5 — Reset (unregister all, keep instance)

ts
for (const def of registry.list()) registry.unregister(def.name);

Or just create a fresh one: PredicateRegistry.empty().


Migration & gotchas

Alias ↔ canonical name collisions rejected at register time

You cannot register an alias that's already another predicate's canonical name, or another alias, or register a name that's already an alias. The registry throws on collision — catch it at config-load time, not at runtime.

isAggregate + singleValued are mutually exclusive

A predicate can't be both an aggregate counter and a single-valued attribute. The registry throws at register time if you try.

Canonicalization doesn't reach queries automatically

memory.findFacts({ predicate: 'worksAt' }) will NOT match facts stored as works_at. The registry's canonicalization runs on the write path (addFact). For queries, call memory.canonicalizePredicate(input) yourself, or use canonical names directly.

Rationale: findFacts is adapter-level; canonicalization is a MemorySystem-level concern. Adding it to every read would couple layers.

The profile predicate is load-bearing

MemorySystem.getContext looks for predicate: 'profile', kind: 'document'. The standard registry ships this predicate with that exact name. If you unregister it or rename it, profile retrieval breaks.

Freshly-created entity vs. auto-supersede timing

Auto-supersede does a synchronous findFacts for the prior visible fact for (subject, predicate). If two concurrent addFact calls arrive for the same singleValued predicate at the same subject, both may see "no prior" and write duplicates. For strict correctness under concurrency, serialize writes for the same (subject, predicate) at the caller layer. In practice this rarely matters (single-process Node, await boundaries).


See also

Documentation snapshot · 2026-11-02Suggest a correction ↗

Search documentation

Type to search the v2 documentation.