diff --git a/IdleSpectator/ActorRelation.cs b/IdleSpectator/ActorRelation.cs index 22ff821..37124b6 100644 --- a/IdleSpectator/ActorRelation.cs +++ b/IdleSpectator/ActorRelation.cs @@ -10,6 +10,70 @@ namespace IdleSpectator; /// public static class ActorRelation { + private static readonly MethodInfo GetChildrenMethod = typeof(Actor).GetMethod( + "getChildren", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + private static readonly MethodInfo GetParentsMethod = typeof(Actor).GetMethod( + "getParents", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + private static readonly Dictionary> ObservedChildrenByParent = + new Dictionary>(); + private static readonly HashSet ParentLinksScanned = + new HashSet(); + + public static void ClearObservedRelations() + { + ObservedChildrenByParent.Clear(); + ParentLinksScanned.Clear(); + } + + public static void ObserveParentLinks(Actor child) + { + if (child == null) + { + return; + } + + long childId = EventFeedUtil.SafeId(child); + if (childId == 0 || !ParentLinksScanned.Add(childId)) + { + return; + } + + foreach (Actor parent in EnumerateParents(child, 2)) + { + RememberParentChild(parent, child); + } + } + + public static void RememberParentChild(Actor parent, Actor child) + { + long parentId = EventFeedUtil.SafeId(parent); + long childId = EventFeedUtil.SafeId(child); + if (parentId == 0 || childId == 0 || parentId == childId) + { + return; + } + + ParentLinksScanned.Add(childId); + if (!ObservedChildrenByParent.TryGetValue(parentId, out List children)) + { + children = new List(2); + ObservedChildrenByParent[parentId] = children; + } + + if (!children.Contains(childId)) + { + children.Add(childId); + } + } + public static Actor GetLover(Actor actor) { if (actor == null) @@ -154,13 +218,14 @@ public static class ActorRelation yield break; } + var seen = new HashSet(); + int yielded = 0; IEnumerable children = null; try { - MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes); - if (getChildren != null) + if (GetChildrenMethod != null) { - children = getChildren.Invoke(actor, null) as IEnumerable; + children = GetChildrenMethod.Invoke(actor, null) as IEnumerable; } } catch @@ -168,21 +233,75 @@ public static class ActorRelation children = null; } - if (children == null) + if (children != null) { - yield break; + foreach (object raw in children) + { + Actor child = raw as Actor ?? ResolveActor(raw); + if (!IsDistinctLivingRelation(actor, child, seen)) + { + continue; + } + + yield return child; + yielded++; + if (yielded >= max) + { + yield break; + } + } } - int yielded = 0; - foreach (object raw in children) + if (ObservedChildrenByParent.TryGetValue( + EventFeedUtil.SafeId(actor), + out List observedChildren)) { - Actor child = raw as Actor ?? ResolveActor(raw); - if (child == null || !child.isAlive()) + for (int i = 0; i < observedChildren.Count; i++) + { + Actor child = EventFeedUtil.FindAliveById(observedChildren[i]); + if (!IsDistinctLivingRelation(actor, child, seen)) + { + continue; + } + + yield return child; + yielded++; + if (yielded >= max) + { + yield break; + } + } + } + + // Some saved civilized bloodlines retain parent links on each child while + // the parent's getChildren() view is empty or incomplete. Family membership + // is not directional, so only promote a peer after its own parent data + // explicitly points back to this actor. + long actorId = EventFeedUtil.SafeId(actor); + int familyScanMax = Math.Min(64, Math.Max(16, max * 8)); + foreach (Actor peer in EnumerateFamilyMembers(actor, familyScanMax)) + { + if (peer == null || peer == actor) { continue; } - yield return child; + bool namesActorAsParent = false; + foreach (Actor parent in EnumerateParents(peer, 2)) + { + if (EventFeedUtil.SafeId(parent) == actorId) + { + namesActorAsParent = true; + break; + } + } + + if (!namesActorAsParent || !IsDistinctLivingRelation(actor, peer, seen)) + { + continue; + } + + yield return peer; yielded++; if (yielded >= max) { @@ -199,7 +318,20 @@ public static class ActorRelation yield break; } - object parents = ReadMember(actor, "getParents", "get_parents", "parents"); + object parents = null; + try + { + parents = GetParentsMethod?.Invoke(actor, null); + } + catch + { + parents = null; + } + + if (parents == null) + { + parents = ReadMember(actor, "get_parents", "parents"); + } if (parents == null) { parents = ReadMember(actor.data, "parents", "parent_ids", "parents_ids"); @@ -366,39 +498,7 @@ public static class ActorRelation private static Actor FindUnitById(long id) { - if (id == 0 || World.world?.units == null) - { - return null; - } - - try - { - foreach (Actor unit in World.world.units) - { - if (unit == null) - { - continue; - } - - try - { - if (unit.getID() == id && unit.isAlive()) - { - return unit; - } - } - catch - { - // skip - } - } - } - catch - { - // ignore - } - - return null; + return EventFeedUtil.FindAliveById(id); } private static Actor ResolveActorId(object raw) @@ -481,7 +581,12 @@ public static class ActorRelation return field.GetValue(instance); } - MethodInfo method = type.GetMethod(name, Type.EmptyTypes); + MethodInfo method = type.GetMethod( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + types: Type.EmptyTypes, + modifiers: null); if (method != null) { return method.Invoke(instance, null); diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index c61a0f5..56e8a72 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1435,9 +1435,20 @@ public static class AgentHarness if (child != null && parent != null && child.isAlive() && parent.isAlive() && child != parent) { - child.setParent1(parent); + bool parent2 = string.Equals( + (cmd.value ?? "").Trim(), + "parent2", + StringComparison.OrdinalIgnoreCase); + if (parent2) + { + child.setParent2(parent); + } + else + { + child.setParent1(parent); + } ok = true; - detail = $"parent={SafeName(parent)} child={SafeName(child)}"; + detail = $"parent={SafeName(parent)} child={SafeName(child)} slot={(parent2 ? 2 : 1)}"; } else { @@ -3683,14 +3694,26 @@ public static class AgentHarness break; } - case "saga_cast_dedupe_probe": + case "saga_cast_stability_probe": { - bool ok = LifeSagaPanel.HarnessProbeScopedCastDedup(out string detail); + bool ok = LifeSagaPanel.HarnessProbeStableCastSnapshot(out string detail); if (ok) _cmdOk++; else _cmdFail++; Emit(cmd, ok, detail: detail); break; } + case "saga_memory_clear_only": + LifeSagaMemory.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "saga_memory_only_cleared"); + break; + + case "narrative_story_clear_only": + NarrativeStoryStore.Clear(); + _cmdOk++; + Emit(cmd, ok: true, detail: "narrative_story_only_cleared"); + break; + case "interest_saga_clear": LifeSagaRoster.Clear(); LifeSagaMemory.Clear(); diff --git a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs index fdf7637..73c3689 100644 --- a/IdleSpectator/Events/Patches/RelationshipEventPatches.cs +++ b/IdleSpectator/Events/Patches/RelationshipEventPatches.cs @@ -164,6 +164,11 @@ public static class RelationshipEventPatches private static void EmitAddChild(Actor parent, Actor child, bool increaseChildren) { + if (increaseChildren && EventOutcome.IsLiving(parent) && EventOutcome.IsLiving(child)) + { + ActorRelation.RememberParentChild(parent, child); + } + EventObservation.Confirm( "setParent", "add_child", diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 9a2fbb7..3ccbecc 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -266,8 +266,10 @@ internal static class HarnessScenarios return SagaSessionReset(); case "saga_relation_cache_revision": return SagaRelationCacheRevision(); - case "saga_cast_dedupe_scoped": - return SagaCastDedupeScoped(); + case "saga_cast_stable_snapshot": + return SagaCastStableSnapshot(); + case "saga_live_child_reverse": + return SagaLiveChildReverse(); case "saga_hover_read_pause": return SagaHoverReadPause(); case "saga_admit_roles": @@ -488,7 +490,7 @@ internal static class HarnessScenarios Nested("reg_narrative_replay", "narrative_editorial_replay"), Nested("reg_caption_status_banner", "caption_status_banner_live"), Nested("reg_saga_relation_revision", "saga_relation_cache_revision"), - Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"), + Nested("reg_saga_cast_snapshot", "saga_cast_stable_snapshot"), Nested("reg_story_aftermath_war", "story_aftermath_war"), Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"), Nested("reg_story_spine", "story_spine_scoped"), @@ -4300,17 +4302,65 @@ internal static class HarnessScenarios }; } - private static List SagaCastDedupeScoped() + private static List SagaCastStableSnapshot() { return new List { - Step("scds0", "wait_world"), - Step("scds1", "saga_cast_dedupe_probe"), - Step("scds2", "assert", expect: "no_bad"), + Step("scds0", "dismiss_windows"), + Step("scds1", "wait_world"), + Step("scds2", "set_setting", expect: "enabled", value: "true"), + Step("scds3", "spawn", asset: "human", count: 2), + Step("scds4", "spectator", value: "off"), + Step("scds5", "spectator", value: "on"), + Step("scds6", "interest_saga_clear"), + Step("scds7", "pick_unit", asset: "human"), + Step("scds8", "happiness_remember_partner"), + Step("scds9", "pick_unit", asset: "human", value: "other"), + Step("scds10", "focus", asset: "human"), + Step("scds11", "saga_force_admit_focus"), + Step("scds12", "saga_memory_record_focus", asset: "child"), + Step("scds13", "assert", expect: "saga_cast_contains", value: "Child"), + Step("scds14", "saga_memory_clear_only"), + Step("scds15", "assert", expect: "saga_cast_contains", value: "Child"), + Step("scds16", "saga_memory_record_focus", asset: "child"), + Step("scds17", "assert", expect: "saga_memory_has_focus", value: "true"), + Step("scds18", "saga_cast_stability_probe"), + Step("scds19", "saga_show_hover_first"), + Step("scds20", "assert", expect: "saga_panel_bound"), + Step("scds21", "assert", expect: "saga_panel_cast_relations", value: "1"), + Step("scds22", "assert", expect: "saga_cast_contains", value: "Child"), + Step("scds23", "assert", expect: "no_bad"), Step("scds99", "snapshot"), }; } + private static List SagaLiveChildReverse() + { + return new List + { + Step("slcr0", "dismiss_windows"), + Step("slcr1", "wait_world"), + Step("slcr2", "set_setting", expect: "enabled", value: "true"), + Step("slcr3", "spawn", asset: "human", count: 2), + Step("slcr4", "spectator", value: "off"), + Step("slcr5", "spectator", value: "on"), + Step("slcr6", "interest_saga_clear"), + Step("slcr7", "pick_unit", asset: "human"), + Step("slcr8", "happiness_remember_partner"), + Step("slcr9", "pick_unit", asset: "human", value: "other"), + Step("slcr10", "focus", asset: "human"), + Step("slcr11", "relationship_set_parent", value: "parent2"), + Step("slcr12", "focus_partner"), + Step("slcr13", "saga_force_admit_focus"), + Step("slcr14", "assert", expect: "saga_cast_contains", value: "Child"), + Step("slcr15", "saga_memory_clear_only"), + Step("slcr16", "narrative_story_clear_only"), + Step("slcr17", "assert", expect: "saga_cast_contains", value: "Child"), + Step("slcr18", "assert", expect: "no_bad"), + Step("slcr99", "snapshot"), + }; + } + /// Character beat caption: live MC identity, prose bans, hover keeps beat. private static List CaptionCharacterBeat() { diff --git a/IdleSpectator/Narrative/NarrativeEventRecorder.cs b/IdleSpectator/Narrative/NarrativeEventRecorder.cs index e44a243..7bbb87f 100644 --- a/IdleSpectator/Narrative/NarrativeEventRecorder.cs +++ b/IdleSpectator/Narrative/NarrativeEventRecorder.cs @@ -281,6 +281,10 @@ public static class NarrativeEventRecorder NarrativeEventRecord existing = NarrativeEventStore.Get(id); if (existing != null) { + // The immutable event store and compact Saga memory have separate + // lifetimes. Re-observing a confirmed event should repair a missing + // presentation fact instead of leaving the relationship generic. + recordFact?.Invoke(); NarrativePresentationModel duplicatePresentation = string.IsNullOrEmpty(eventId) ? null diff --git a/IdleSpectator/Narrative/NarrativeEventStore.cs b/IdleSpectator/Narrative/NarrativeEventStore.cs index a97e611..aff37ff 100644 --- a/IdleSpectator/Narrative/NarrativeEventStore.cs +++ b/IdleSpectator/Narrative/NarrativeEventStore.cs @@ -9,12 +9,18 @@ public static class NarrativeEventStore { private static readonly Dictionary Events = new Dictionary(StringComparer.Ordinal); + private static readonly Dictionary> ChildEventsByParent = + new Dictionary>(); + private static readonly Dictionary> ChildEventsByChild = + new Dictionary>(); public static int Count => Events.Count; public static void Clear() { Events.Clear(); + ChildEventsByParent.Clear(); + ChildEventsByChild.Clear(); } public static NarrativeEventRecord Get(string id) @@ -36,6 +42,7 @@ public static class NarrativeEventStore } Events[record.Id] = record; + Index(record); NarrativePersistence.MarkDirty(); return true; } @@ -50,9 +57,24 @@ public static class NarrativeEventStore } Events[record.Id] = record; + Index(record); return true; } + public static IReadOnlyList ChildrenOf(long parentId) + { + return parentId != 0 && ChildEventsByParent.TryGetValue(parentId, out List records) + ? records + : Array.Empty(); + } + + public static IReadOnlyList ParentsOf(long childId) + { + return childId != 0 && ChildEventsByChild.TryGetValue(childId, out List records) + ? records + : Array.Empty(); + } + /// /// Keep the event side of the runtime graph aligned with retained developments. /// Recent unprojected observations fill any remaining room so dedupe remains useful. @@ -99,6 +121,50 @@ public static class NarrativeEventStore if (!retained.Contains(id)) remove.Add(id); } for (int i = 0; i < remove.Count; i++) Events.Remove(remove[i]); + if (remove.Count > 0) + { + RebuildRelationIndexes(); + } return Mathf.Max(0, before - Events.Count); } + + private static void Index(NarrativeEventRecord record) + { + if (record == null || record.Kind != NarrativeEventKind.ChildBorn) + { + return; + } + + AddIndexed(ChildEventsByParent, record.SubjectId, record); + AddIndexed(ChildEventsByChild, record.OtherId, record); + } + + private static void AddIndexed( + Dictionary> index, + long id, + NarrativeEventRecord record) + { + if (id == 0) + { + return; + } + + if (!index.TryGetValue(id, out List records)) + { + records = new List(2); + index[id] = records; + } + + records.Add(record); + } + + private static void RebuildRelationIndexes() + { + ChildEventsByParent.Clear(); + ChildEventsByChild.Clear(); + foreach (NarrativeEventRecord record in Events.Values) + { + Index(record); + } + } } diff --git a/IdleSpectator/Story/LifeSagaPanel.cs b/IdleSpectator/Story/LifeSagaPanel.cs index b2673f6..5af286e 100644 --- a/IdleSpectator/Story/LifeSagaPanel.cs +++ b/IdleSpectator/Story/LifeSagaPanel.cs @@ -172,7 +172,7 @@ public static class LifeSagaPanel for (int i = 0; i < model.Cast.Count; i++) { LifeSagaCastMember member = model.Cast[i]; - if (member == null || CastDuplicatesCurrentBeat(model.UnitId, member.UnitId)) + if (member == null || member.UnitId == 0) { continue; } @@ -259,24 +259,6 @@ public static class LifeSagaPanel float y = Pad; float yMax = BodyHeightFixed - Pad; - // The Beat can update after the model binds in the same frame. Recheck by - // structured ids; display-name substring matching removed legitimate Cast - // from a different hovered MC and collided on short names. - for (int i = 0; i < CastSlots.Length; i++) - { - CastSlot slot = CastSlots[i]; - if (slot?.Root == null || !slot.Root.activeSelf || slot.Name == null) - { - continue; - } - - if (CastDuplicatesCurrentBeat(_boundId, slot.UnitId)) - { - slot.Root.SetActive(false); - slot.UnitId = 0; - } - } - int castN = 0; LastVisibleRelationCount = 0; for (int i = 0; i < CastSlots.Length; i++) @@ -463,44 +445,23 @@ public static class LifeSagaPanel slot.Root.SetActive(true); } - /// The camera Beat already explains this exact member for this exact subject. - private static bool CastDuplicatesCurrentBeat(long panelSubjectId, long castMemberId) + /// Harness: Cast membership is independent of the transient orange Beat. + internal static bool HarnessProbeStableCastSnapshot(out string detail) { - long cameraSubjectId = EventFeedUtil.SafeId( - MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null); - return ShouldDeduplicateCast( - panelSubjectId, - castMemberId, - cameraSubjectId, - WatchCaption.VisibleReasonRelatedId, - !string.IsNullOrEmpty(WatchCaption.VisibleBeatLine)); - } - - private static bool ShouldDeduplicateCast( - long panelSubjectId, - long castMemberId, - long cameraSubjectId, - long beatRelatedId, - bool hasVisibleBeat) - { - return hasVisibleBeat - && panelSubjectId != 0 - && castMemberId != 0 - && cameraSubjectId == panelSubjectId - && beatRelatedId == castMemberId; - } - - /// Harness: structured Cast dedupe truth table without display-name parsing. - internal static bool HarnessProbeScopedCastDedup(out string detail) - { - bool sameSubjectExact = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: true); - bool otherHoveredSubject = ShouldDeduplicateCast(11, 20, 10, 20, hasVisibleBeat: true); - bool differentMember = ShouldDeduplicateCast(10, 21, 10, 20, hasVisibleBeat: true); - bool noBeat = ShouldDeduplicateCast(10, 20, 10, 20, hasVisibleBeat: false); - bool pass = sameSubjectExact && !otherHoveredSubject && !differentMember && !noBeat; - detail = - $"pass={pass} sameExact={sameSubjectExact} otherHover={otherHoveredSubject} " - + $"differentMember={differentMember} noBeat={noBeat}"; + var model = new LifeSagaViewModel { UnitId = 10, Name = "Parent" }; + model.Cast.Add(new LifeSagaCastMember + { + UnitId = 20, + Name = "Child", + Relation = "Child", + Alive = true + }); + string before = Fingerprint(model); + string after = Fingerprint(model); + bool pass = before == after + && model.Cast.Count == 1 + && model.Cast[0].UnitId == 20; + detail = $"pass={pass} fingerprintStable={before == after} cast={model.Cast.Count} member={model.Cast[0].UnitId}"; return pass; } @@ -568,9 +529,7 @@ public static class LifeSagaPanel private static string Fingerprint(LifeSagaViewModel model) { var sb = new StringBuilder(192); - sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine) - .Append('|').Append(WatchCaption.VisibleBeatLine) - .Append('|').Append(WatchCaption.VisibleReasonRelatedId); + sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.StakeLine); for (int i = 0; i < model.Cast.Count; i++) { var c = model.Cast[i]; diff --git a/IdleSpectator/Story/LifeSagaPresentation.cs b/IdleSpectator/Story/LifeSagaPresentation.cs index 5c8aca4..9a44bd0 100644 --- a/IdleSpectator/Story/LifeSagaPresentation.cs +++ b/IdleSpectator/Story/LifeSagaPresentation.cs @@ -574,9 +574,36 @@ public static class LifeSagaPresentation { AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed"); } - else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null) + else if (f.Kind == LifeSagaFactKind.ParentChild) { - AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed"); + string relation = LifeSagaMemory.IsParentPerspective(f) ? "child" : "parent"; + AddObserved(f.Other, SagaProse.CastLabelLive(relation), "observed"); + } + } + } + + // Confirmed birth events are the canonical directional fallback. WorldBox + // family membership is broader than parenthood, and its getChildren view + // can be incomplete for an already-living actor. + if (model.Cast.Count < LifeSagaPanel.MaxCastCandidates) + { + IReadOnlyList children = NarrativeEventStore.ChildrenOf(model.UnitId); + for (int i = 0; i < children.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++) + { + NarrativeEventRecord record = children[i]; + if (record != null) + { + AddObserved(record.Other, SagaProse.CastLabelLive("child"), "observed"); + } + } + + IReadOnlyList parents = NarrativeEventStore.ParentsOf(model.UnitId); + for (int i = 0; i < parents.Count && model.Cast.Count < LifeSagaPanel.MaxCastCandidates; i++) + { + NarrativeEventRecord record = parents[i]; + if (record != null) + { + AddObserved(record.Subject, SagaProse.CastLabelLive("parent"), "observed"); } } } diff --git a/IdleSpectator/Story/LifeSagaRoster.cs b/IdleSpectator/Story/LifeSagaRoster.cs index 22529de..ef0ae5d 100644 --- a/IdleSpectator/Story/LifeSagaRoster.cs +++ b/IdleSpectator/Story/LifeSagaRoster.cs @@ -99,6 +99,7 @@ public static class LifeSagaRoster SpeciesCountCache.Clear(); McOrbitCache.Clear(); NarrativeChallengerScratch.Clear(); + ActorRelation.ClearObservedRelations(); _nextScanAt = 0f; _nextNarrativeAdmissionAt = 0f; _roleCacheAt = -999f; @@ -908,6 +909,7 @@ public static class LifeSagaRoster continue; } + ActorRelation.ObserveParentLinks(actor); long id = EventFeedUtil.SafeId(actor); if (id == 0 || ScanSeen.Contains(id)) { diff --git a/docs/life-saga.md b/docs/life-saga.md index 0b6a679..f008e23 100644 --- a/docs/life-saga.md +++ b/docs/life-saga.md @@ -168,6 +168,13 @@ facts newer than the loaded world time, and rebuilds live actor bindings without events as fresh camera developments. Camera focus, active episodes, cooldowns, and session time are never serialized. +Cast membership is a stable Saga snapshot. It does not disappear merely because the orange camera +beat mentions the same relative; the beat is hidden while Saga is open. Recorded parent/child facts +and indexed confirmed birth events also backfill living relatives when WorldBox's live family +enumeration is temporarily incomplete. Generic family membership is only labeled `Kin` after these +directional sources have been exhausted. For older saves, a bounded reverse lookup also recognizes +a family peer as a child when that peer's own parent data points to the displayed character. + Legacy is authored only by `LegacyChapterBuilder`: 3+ births consolidate into lineage, repeated kills consolidate into a combat chapter, and current Cast/Identity facts do not echo unchanged. diff --git a/docs/story-system-coherence-plan.md b/docs/story-system-coherence-plan.md index e60f551..016a68d 100644 --- a/docs/story-system-coherence-plan.md +++ b/docs/story-system-coherence-plan.md @@ -266,7 +266,7 @@ Recommended scenario names: - `story_cross_arc_correlation` - `caption_status_banner_live` - `saga_relation_cache_revision` -- `saga_cast_dedupe_scoped` +- `saga_cast_stable_snapshot` - `saga_hover_max_content` ### D3. Verification order @@ -335,7 +335,7 @@ Suggested commit boundaries: - New focused probes passed three consecutive runs each: `story_cross_arc_correlation`, `caption_status_banner_live`, - `saga_relation_cache_revision`, and `saga_cast_dedupe_scoped`. + `saga_relation_cache_revision`, and `saga_cast_stable_snapshot`. - Existing story, Saga, caption, director, dossier, lifecycle, and critical-smoke scenarios passed individually after the implementation. - A fresh-state full regression passed **19/19 scenarios** on 2026-07-22.