docs(architecture): add interaction memory KG schema design (#442)

Design doc at docs/architecture/interaction-memory-schema.md extending
FactKnowledge with interaction tracking. FactMetadata enum on existing
BTreeMap, 5-state InteractionState (Active/Completed/WalkedAway/
Confrontation/ConflictAbandoned), monologue prerequisite extension.

Includes entity slug resolution approach, NpcTolerance reconciliation,
and fact decay exemption documentation.

Ref: D-041, D-064, D-063

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 00:41:40 +01:00
co-authored by Claude Opus 4.6
parent 91d6b1b1cb
commit 087ff67bfb
@@ -0,0 +1,660 @@
# InteractionMemory Knowledge Graph Schema Design
**Ticket:** #442 | **Sprint:** 7 | **Priority:** MEDIUM
**Decisions:** D-041 (KG data model), D-064 (walk-away consequences), D-063 (confrontation), D-028 (dialogue architecture)
**Audience:** Engine developers (Dudley), narrative designers (Paula, Mellanie), QA (Hoshe)
---
## 1. Purpose
The InteractionMemory schema extends the existing `FactKnowledge` system (D-041) to track conversation history between the player character and NPCs. This enables:
- **Walk-away consequences** (D-064): The KG records that an interaction was started but not finished, affecting future dialogue and NPC behavior.
- **Confrontation tracking** (D-063): The KG records whether an interaction escalated to confrontation.
- **Dialogue history queries**: "Have I talked to X before?", "Did I walk away from X?", "When did I last complete a conversation with X?"
- **Monologue triggers**: Post-interaction reflection that references prior conversation outcomes.
This is a schema design document. It defines data structures and query patterns — implementation follows in a future ticket.
---
## 2. Design Constraints
From D-041:
- All maps are `BTreeMap` (deterministic iteration, non-negotiable per D-010 principle 4)
- Facts live in `KnowledgeGraph.facts: BTreeMap<FactId, FactKnowledge>`
- `FactId` format is `"category.topic"` (string)
- `FactKnowledge` has `confidence`, `source`, `state`, `acquired_tick`
From D-064:
- Walk-away triggers three phases: immediate break, NPC reaction, KG recording
- KG must log that the interaction was initiated but not completed
- This is queryable and affects future dialogue, monologue, and NPC behavior
- NPC tolerance thresholds vary per seed (no universal rules to metagame)
From the existing codebase (`server/src/knowledge/types.rs`):
- `KnowledgeConfidence`: Suspects < KnowsOf < KnowsDetails < Direct
- `KnowledgeState`: Active, Contradicted, Stale
- `KnowledgeSource`: DirectObservation, Heard, ToldBy, Inferred, Background
- `RelationshipState`: Unknown, Known, Friendly, PersonOfInterest, Hostile
---
## 3. Schema Extension
### 3.1 Interaction FactId format
Interaction facts use a dedicated `interaction` category in the FactId namespace:
```
interaction.{entity_slug}_{sequence}
```
| Segment | Format | Description |
|---------|--------|-------------|
| `interaction` | literal | Category prefix — distinguishes from other fact categories |
| `entity_slug` | kebab-case | NPC slug matching their content file (e.g., `kael-davan`, `lera-sessik`) |
| `_sequence` | `_NNN` | Zero-padded sequence number for multiple interactions with the same NPC |
**Examples:**
- `interaction.kael-davan_001` — first tracked interaction with Kael
- `interaction.kael-davan_002` — second tracked interaction with Kael
- `interaction.sera-venn_001` — first tracked interaction with Sera
**Why sequence numbers, not timestamps?** FactIds are string keys in a BTreeMap. Sequence numbers are shorter, sortable, and don't tie the ID format to the tick system. The timestamp is stored in the metadata (see Section 3.3).
#### Entity slug resolution at runtime
The `{entity_slug}` segment in the FactId (e.g., `kael-davan`) must be resolvable from an `Entity` (bevy ECS handle) during gameplay. Currently, no component maps Entity → content slug at runtime.
**Proposed approach:** Add a `ContentSlug` component to the NPC entity bundle, populated from the NPC profile filename during spawn:
```rust
/// Stable content identifier for an entity, derived from its YAML profile filename.
/// e.g., NPC profile `kael-davan.yaml` → ContentSlug("kael-davan").
/// Used to construct FactIds like `interaction.kael-davan_001`.
#[derive(Component, Debug, Clone)]
pub struct ContentSlug(pub String);
```
The interaction event handler resolves Entity → ContentSlug → FactId:
```rust
// In InteractionStarted event processing:
let slug = world.get::<ContentSlug>(target)
.expect("NPC must have ContentSlug component");
let seq = next_interaction_sequence(&kg.facts, &slug.0);
let fact_id = FactId(format!("interaction.{}_{:03}", slug.0, seq));
```
**Implementation note:** The `ContentSlug` component is a prerequisite for this schema. It should be added as part of the NPC spawn pipeline (#423 or a follow-up ticket) before interaction tracking is implemented. Until `ContentSlug` exists, the interaction system cannot construct FactIds.
### 3.2 InteractionState enum
New enum representing the outcome of an interaction:
```rust
/// Outcome state of a tracked interaction.
/// Drives walk-away mechanics (D-064) and confrontation tracking (D-063).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InteractionState {
/// Conversation started, still in progress.
/// Set when dialogue box opens. Cleared on transition to another state.
Active,
/// Conversation completed normally (both parties finished talking).
/// The default "good" outcome.
Completed,
/// Player walked away mid-conversation (D-064 Phase 3).
/// The KG records incompleteness. NPC tolerance threshold determines
/// severity of consequence.
WalkedAway,
/// Conversation escalated to confrontation (D-063).
/// Player made an accusation or NPC became hostile during dialogue.
/// Separate from WalkedAway — a confrontation can be completed.
Confrontation,
/// Confrontation was initiated but player walked away before resolution.
/// The most consequential outcome — walking away from a confrontation
/// you started is worse than walking away from smalltalk.
ConflictAbandoned,
}
```
**State transitions:**
```
┌─────────────┐
│ Active │ ← dialogue opens
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌──────────┐ ┌──────────────┐
│ Completed │ │ WalkedAway│ │Confrontation │
└───────────┘ └──────────┘ └──────┬───────┘
┌─────────┼──────────┐
│ │
▼ ▼
┌───────────┐ ┌──────────────────┐
│ Completed │ │ConflictAbandoned │
└───────────┘ └──────────────────┘
```
All transitions are one-way. Once an interaction reaches a terminal state (Completed, WalkedAway, ConflictAbandoned), it cannot change.
### 3.3 InteractionMetadata
New metadata struct attached to interaction facts. This extends the base `FactKnowledge` with interaction-specific data.
```rust
/// Metadata for an interaction fact.
/// Stored alongside the FactKnowledge in the knowledge graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionMetadata {
/// The NPC this interaction was with.
pub target_entity: StableId,
/// Interaction outcome.
pub interaction_state: InteractionState,
/// Tick when dialogue was initiated.
pub started_tick: u64,
/// Tick when interaction ended (completed, walked away, etc.).
/// None if interaction is still Active.
pub ended_tick: Option<u64>,
/// Primary conversation topic(s) discussed.
/// References topic enum values from content/global/enums/topics.yaml.
/// Empty if no specific topic was identified.
pub topics: Vec<String>,
/// Whether a confrontation occurred during this interaction.
/// True if InteractionState is Confrontation or ConflictAbandoned.
pub confrontation: bool,
/// Whether the player initiated the conversation (approached NPC)
/// or the NPC initiated (unprompted disclosure, D-028 Layer 4).
pub player_initiated: bool,
/// Location slug where the interaction took place.
pub location: String,
}
```
### 3.4 Storage strategy
Interaction metadata is stored in a new field on `FactKnowledge`. There are two viable approaches:
**Option A: Typed metadata field (recommended)**
Add an optional metadata enum to `FactKnowledge`:
```rust
/// Optional typed metadata attached to a fact.
/// Extensible for future fact types beyond interactions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FactMetadata {
Interaction(InteractionMetadata),
// Future: Evidence(EvidenceMetadata), Gossip(GossipMetadata), etc.
}
/// Updated FactKnowledge with optional metadata.
pub struct FactKnowledge {
pub confidence: KnowledgeConfidence,
pub source: KnowledgeSource,
pub state: KnowledgeState,
pub acquired_tick: u64,
pub metadata: Option<FactMetadata>, // NEW
}
```
**Why Option A over alternatives:**
- No separate data structure — interactions are facts, stored in the existing `BTreeMap<FactId, FactKnowledge>`
- The `FactMetadata` enum is extensible for future fact types (evidence, gossip) without changing the graph structure
- `Option<FactMetadata>` adds zero overhead to facts that don't need metadata (the vast majority)
- Queries use existing `KnowledgeGraph.facts` infrastructure — no new maps to maintain
**Option B (rejected): Separate InteractionLog map**
A separate `BTreeMap<InteractionId, InteractionRecord>` on KnowledgeGraph. Rejected because it fragments the knowledge model — interactions ARE facts about the world, and should be queryable through the same API as other facts. Separate storage also complicates serialization and tier transitions (D-026).
---
## 4. Confidence Mapping
Interaction facts use the existing 4-level confidence hierarchy, but the levels have interaction-specific semantics:
| Confidence | Interaction meaning | When set |
|------------|-------------------|----------|
| `Suspects` | Not used for interactions | — |
| `KnowsOf` | "I talked to X" | Set when dialogue starts |
| `KnowsDetails` | "I talked to X about Y, and it ended with Z" | Set when dialogue ends with meaningful outcome |
| `Direct` | "I am talking to X right now" | Set during active dialogue, cleared on end |
Interaction facts start at `Direct` (active conversation) and settle to `KnowsDetails` when the conversation ends. They do not decay below `KnowsDetails` — you don't forget that you had a conversation.
#### Fact decay exemption status
The current `decay_knowledge` system (`server/src/knowledge/events.rs:106`) only iterates `KnowledgeGraph.entities` (EntityKnowledge), **not** `KnowledgeGraph.facts` (FactKnowledge). This means interaction facts stored in `self.facts` are **inherently immune to decay today** — no code path touches them.
However, this is an implementation coincidence, not an explicit guarantee. When a future fact decay system is built (to handle stale evidence, fading gossip, etc.), it **must** respect `DECAY_FLOOR` for interaction facts. Interaction facts should never decay below `KnowsDetails` — you remember that you had a conversation, even if other details fade.
**Requirement for future fact decay implementation:** Check `FactMetadata::Interaction` and clamp confidence at `DECAY_FLOOR`:
```rust
impl InteractionMetadata {
/// Interaction facts do not decay below KnowsDetails.
/// You remember that you had a conversation, even if details fade.
pub const DECAY_FLOOR: KnowledgeConfidence = KnowledgeConfidence::KnowsDetails;
}
// In future fact decay system:
for (id, fact) in kg.facts.iter_mut() {
if let Some(FactMetadata::Interaction(_)) = &fact.metadata {
// Interaction facts: clamp at DECAY_FLOOR
if fact.confidence.decayed() < InteractionMetadata::DECAY_FLOOR {
continue; // Skip — would drop below floor
}
}
// ... normal decay logic ...
}
```
---
## 5. Query Patterns
### 5.1 Core queries
These are the queries the dialogue system, monologue system, and NPC behavior need:
```rust
impl KnowledgeGraph {
/// Has the player ever interacted with this entity?
fn has_interacted_with(&self, target: StableId) -> bool;
/// Get the most recent interaction with an entity.
fn last_interaction_with(&self, target: StableId) -> Option<&InteractionMetadata>;
/// Get all interactions with an entity, ordered by started_tick.
fn interactions_with(&self, target: StableId) -> Vec<&InteractionMetadata>;
/// Has the player walked away from this entity?
fn has_walked_away_from(&self, target: StableId) -> bool;
/// Count of interactions with an entity.
fn interaction_count_with(&self, target: StableId) -> usize;
/// Has the player ever had a confrontation with this entity?
fn has_confronted(&self, target: StableId) -> bool;
/// Is there currently an active (in-progress) interaction?
fn active_interaction(&self) -> Option<&InteractionMetadata>;
}
```
### 5.2 Implementation sketch
All queries scan `self.facts` for entries with `interaction.{slug}` prefix and `FactMetadata::Interaction` metadata. Since BTreeMap iterates in lexicographic order, all interactions with the same entity are contiguous (they share the `interaction.{slug}` prefix).
```rust
/// Iterate all interaction facts for a given entity.
/// Uses BTreeMap range query for O(log N + K) where K = number of interactions.
fn interaction_facts_for<'a>(
&'a self,
entity_slug: &str,
) -> impl Iterator<Item = (&'a FactId, &'a InteractionMetadata)> {
let prefix = format!("interaction.{entity_slug}_");
self.facts
.range(FactId(prefix.clone())..)
.take_while(move |(id, _)| id.0.starts_with(&prefix))
.filter_map(|(id, fact)| {
match &fact.metadata {
Some(FactMetadata::Interaction(meta)) => Some((id, meta)),
_ => None,
}
})
}
```
**Performance:** At expected scale (10-30 interactions per playthrough), this is negligible. BTreeMap range queries are O(log N) to find the start, then O(K) to iterate matches. With N=50 total facts and K=5 interactions per NPC, this is ~6-8 comparisons + 5 iterations.
### 5.3 Dialogue system queries
The dialogue selection pipeline (D-028) needs interaction history for Layer 2 (relationship history):
| Query | Use case | Implementation |
|-------|----------|----------------|
| "First time talking to X?" | Greeting selection — fresh vs. returning | `interaction_count_with(target) == 0` |
| "Did I walk away last time?" | NPC reacts differently, may reference it | `last_interaction_with(target).state == WalkedAway` |
| "How many times have we talked?" | Familiarity progression | `interaction_count_with(target)` |
| "Was there a confrontation?" | Permanently shifts NPC posture | `has_confronted(target)` |
| "Active conversation right now?" | Block new dialogue initiation | `active_interaction().is_some()` |
### 5.4 Monologue system queries
The monologue system (D-032, D-035) uses interaction facts as prerequisites:
```yaml
# Monologue line that fires after walking away from Kael
- id: the-last-shift_m_s_050
text: "Shouldn't have left like that. Kael noticed."
trigger: post_conversation
prerequisites:
facts:
- fact_id: interaction.kael-davan_001
min_confidence: knows_details
tags: [kael, walk-away, regret]
```
For more precise gating on interaction outcome, the monologue system needs to check the `InteractionMetadata.interaction_state` field. This extends the prerequisite system:
```yaml
# More specific: only fires if the player walked away
prerequisites:
facts:
- fact_id: interaction.kael-davan_001
min_confidence: knows_details
interaction: # NEW prerequisite type
target: npc:kael-davan
state: walked_away # matches InteractionState::WalkedAway
```
This extends the monologue prerequisite schema (Section 6).
---
## 6. Monologue Prerequisite Extension
The existing `monologue-pool.schema.json` supports `facts`, `entity_attributes`, and `relationship` prerequisites. Interaction memory adds a new prerequisite type:
```json
"interaction_prerequisite": {
"type": "object",
"required": ["target"],
"additionalProperties": false,
"properties": {
"target": {
"type": "string",
"description": "Entity reference (e.g., npc:kael-davan)"
},
"state": {
"type": "string",
"enum": ["active", "completed", "walked_away", "confrontation", "conflict_abandoned"],
"description": "Required interaction outcome. If omitted, any interaction satisfies."
},
"min_count": {
"type": "integer",
"minimum": 1,
"default": 1,
"description": "Minimum number of interactions matching the criteria"
}
}
}
```
Added to the `prerequisites` object in the monologue schema:
```json
"prerequisites": {
"type": "object",
"properties": {
"facts": { ... },
"entity_attributes": { ... },
"relationship": { ... },
"interaction": { "$ref": "#/$defs/interaction_prerequisite" }
}
}
```
**Examples:**
```yaml
# Fires after any interaction with Kael (first meeting)
prerequisites:
interaction:
target: npc:kael-davan
# Fires only if player walked away from Kael
prerequisites:
interaction:
target: npc:kael-davan
state: walked_away
# Fires after 3+ completed conversations with Lera
prerequisites:
interaction:
target: npc:lera-sessik
state: completed
min_count: 3
# Combined: walked away from Kael AND knows about the ring
prerequisites:
facts:
- fact_id: contraband.ring_exists
min_confidence: knows_of
interaction:
target: npc:kael-davan
state: walked_away
```
---
## 7. Event Integration
### 7.1 New KnowledgeEventType variants
Extend `KnowledgeEventType` in `server/src/knowledge/events.rs`:
```rust
pub enum KnowledgeEventType {
// ... existing variants ...
/// Dialogue initiated with an NPC.
InteractionStarted {
target: Entity,
player_initiated: bool,
location: String,
},
/// Dialogue completed normally.
InteractionCompleted {
target: Entity,
topics: Vec<String>,
},
/// Player walked away mid-conversation (D-064).
InteractionWalkedAway {
target: Entity,
},
/// Conversation escalated to confrontation (D-063).
InteractionConfrontation {
target: Entity,
},
/// Player walked away from a confrontation they started.
InteractionConflictAbandoned {
target: Entity,
},
}
```
### 7.2 Event flow
```
Player approaches NPC → dialogue system opens
├─ Emit InteractionStarted
│ → KG creates interaction.{slug}_{seq} fact with InteractionState::Active
│ → FactKnowledge.confidence = Direct (in conversation now)
├─ ... conversation proceeds ...
└─ Conversation ends (one of):
├─ Normal end → Emit InteractionCompleted
│ → KG updates fact: Active → Completed, confidence → KnowsDetails
│ → Record topics discussed
├─ Player walks away (WASD) → Emit InteractionWalkedAway
│ → KG updates fact: Active → WalkedAway, confidence → KnowsDetails
│ → D-064 Phase 3: KG records incompleteness
├─ Confrontation escalation → Emit InteractionConfrontation
│ → KG updates fact: Active → Confrontation
│ → Conversation continues (may still complete or be abandoned)
└─ Walk away from confrontation → Emit InteractionConflictAbandoned
→ KG updates fact: Confrontation → ConflictAbandoned
→ Most severe consequence path
```
### 7.3 Sequence number allocation
The `process_knowledge_events` system allocates sequence numbers when processing `InteractionStarted` events:
```rust
/// Determine next sequence number for an interaction FactId.
/// Scans existing facts for the highest sequence number with this prefix.
fn next_interaction_sequence(
facts: &BTreeMap<FactId, FactKnowledge>,
entity_slug: &str,
) -> u32 {
let prefix = format!("interaction.{entity_slug}_");
facts
.range(FactId(prefix.clone())..)
.take_while(|(id, _)| id.0.starts_with(&prefix))
.count() as u32
+ 1
}
```
This is O(log N + K) — acceptable for the expected interaction count.
---
## 8. NPC Tolerance System
Per D-064, walk-away consequences vary by NPC tolerance threshold per seed. This is orthogonal to the KG schema — it lives on the NPC entity, not in the player's knowledge graph.
### 8.1 Relationship to existing tolerance types
The codebase already has two tolerance-related types:
| Type | Location | Purpose |
|------|----------|---------|
| `ToleranceThreshold` | `server/src/npc/mod.rs:122` | **ECS Component.** General NPC stress tracker with `current_stress: i16` and `threshold: i16`. Drives overall NPC frustration behavior (D-024 Axis 4). |
| `NpcTolerance` | `server/src/content/types.rs:302` | **Content definition.** Deserialization struct from NPC YAML profiles with `threshold: Option<i32>`. Populates `ToleranceThreshold` at spawn. |
The proposed `InteractionTolerance` is **not a replacement** for either. It tracks walk-awayspecific consequence parameters that `ToleranceThreshold` does not cover (penalty magnitude, forgiveness policy). The relationship:
- `NpcTolerance` (content YAML) → populates `ToleranceThreshold` (ECS runtime)
- `ToleranceThreshold` tracks **general stress accumulation** — any irritant contributes
- `InteractionTolerance` tracks **walk-awayspecific consequence parameters** — how the NPC reacts to conversational abandonment specifically
**Recommended implementation approach:** Rather than adding a third standalone component, extend the existing `ToleranceThreshold` or co-locate walk-away fields on a new `InteractionTolerance` component that **reads** `ToleranceThreshold.current_stress` as a factor. Walk-away consequences should feed back into `ToleranceThreshold.current_stress` (walking away from a stressed NPC is worse). The walk-away system would:
1. Check `InteractionTolerance.walk_away_threshold` — has the NPC been walked away from too many times?
2. If threshold exceeded, apply `walk_away_penalty` to relationship
3. Increase `ToleranceThreshold.current_stress` by a walk-awayspecific delta
4. Check `permanent_confrontation_memory` for confrontation abandonment
```rust
/// Walk-awayspecific consequence parameters. Extends general tolerance
/// (ToleranceThreshold) with interaction-specific mechanics per D-064.
/// Populated from NPC profile YAML, seeded per playthrough.
/// Reads ToleranceThreshold.current_stress as an aggravating factor.
#[derive(Component, Debug, Clone)]
pub struct InteractionTolerance {
/// How many walk-aways before relationship degradation.
/// Range: 1-5, seeded per NPC per playthrough.
pub walk_away_threshold: u8,
/// How much relationship damage per walk-away beyond threshold.
/// Range: 0.1-0.5, seeded per NPC.
pub walk_away_penalty: f32,
/// Stress delta applied to ToleranceThreshold.current_stress per walk-away.
/// A walk-away from a stressed NPC (high current_stress) hits harder.
pub walk_away_stress_delta: i16,
/// Whether this NPC remembers confrontation abandonment permanently.
/// Some NPCs forgive, some don't (per seed).
pub permanent_confrontation_memory: bool,
}
```
The NPC behavior system queries both the player's KG (for interaction history) and the NPC's tolerance components (for consequence magnitude). This separation means:
- The player's KG is what THEY remember
- `ToleranceThreshold` is the NPC's general frustration level
- `InteractionTolerance` is how the NPC reacts to walk-aways specifically (feeding back into general stress)
- The NPC may also have their own KG entry for the interaction (future: NPC-to-NPC gossip about "the person who walked out on Kael")
---
## 9. Scope and Limitations
### 9.1 v0.1 scope (this ticket)
- Player character interaction tracking only
- Interactions with Active-tier NPCs (30-80 per D-026)
- Single `interaction` prerequisite per monologue line
- No NPC-to-NPC interaction tracking
- No gossip about interactions ("I heard you walked out on Kael")
### 9.2 Future extensions
| Feature | Ticket/Sprint | Schema impact |
|---------|--------------|---------------|
| NPC-to-NPC interaction tracking | Future | Same schema, applied to NPC KnowledgeGraphs |
| Gossip about interactions | Sprint 3+ (ToldBy source) | New `KnowledgeSource::ToldBy` entries referencing interaction facts |
| Interaction-driven relationship decay | Future | Query interaction history to modulate decay rates |
| Multi-NPC conversations | Future | `InteractionMetadata.target_entity` becomes `Vec<StableId>` |
### 9.3 Memory budget
Per-interaction memory cost: ~200 bytes (InteractionMetadata + FactKnowledge overhead).
Expected interactions per playthrough: 50-150 (2-5 per Active NPC).
Total interaction memory: ~10-30 KB per player character. Negligible against the D-041 budget of ~6 MB for the full knowledge graph.
---
## 10. Schema Summary
| Component | Location | Type | Description |
|-----------|----------|------|-------------|
| `InteractionState` | `server/src/knowledge/types.rs` | enum | 5 outcome states |
| `InteractionMetadata` | `server/src/knowledge/types.rs` | struct | Per-interaction data |
| `FactMetadata` | `server/src/knowledge/types.rs` | enum | Extensible typed metadata on FactKnowledge |
| `FactKnowledge.metadata` | `server/src/knowledge/types.rs` | `Option<FactMetadata>` | New field on existing struct |
| `ContentSlug` | `server/src/npc/` (proposed) | Component | Entity → content slug mapping for FactId construction |
| `InteractionTolerance` | NPC template system | Component | Walk-away consequence parameters (extends ToleranceThreshold) |
| `interaction_prerequisite` | `content/_schema/monologue-pool.schema.json` | schema | New prerequisite type |
| `KnowledgeEventType` variants | `server/src/knowledge/events.rs` | enum variants | 5 new event types |
| Interaction FactId format | Convention | `interaction.{slug}_{NNN}` | Fact category for interactions |
### Breaking changes
One field addition to an existing struct:
```diff
pub struct FactKnowledge {
pub confidence: KnowledgeConfidence,
pub source: KnowledgeSource,
pub state: KnowledgeState,
pub acquired_tick: u64,
+ pub metadata: Option<FactMetadata>,
}
```
This is an additive change. Existing facts have `metadata: None`. All existing queries continue to work unchanged. The only code change required beyond adding the field is to add `metadata: None` to existing `FactKnowledge` construction sites.