diff --git a/docs/architecture/interaction-memory-schema.md b/docs/architecture/interaction-memory-schema.md new file mode 100644 index 000000000..d9b0dd8b0 --- /dev/null +++ b/docs/architecture/interaction-memory-schema.md @@ -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` 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::(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, + + /// 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, + + /// 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, // NEW +} +``` + +**Why Option A over alternatives:** +- No separate data structure — interactions are facts, stored in the existing `BTreeMap` +- The `FactMetadata` enum is extensible for future fact types (evidence, gossip) without changing the graph structure +- `Option` 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` 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 { + 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, + }, + + /// 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, + 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`. Populates `ToleranceThreshold` at spawn. | + +The proposed `InteractionTolerance` is **not a replacement** for either. It tracks walk-away–specific 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-away–specific 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-away–specific delta +4. Check `permanent_confrontation_memory` for confrontation abandonment + +```rust +/// Walk-away–specific 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` | + +### 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` | 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, + } +``` + +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.