Inherit rolling chronology for cue-free scenes

This commit is contained in:
Nick Beckley 2026-09-05 21:31:51 +00:00
parent 296a4afb99
commit 10da445713
3 changed files with 98 additions and 13 deletions

View File

@ -116,6 +116,13 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence temporal rejects unanchored clock time", StoryIntelligenceTemporalRejectsUnanchoredClockTime), ("Story Intelligence temporal rejects unanchored clock time", StoryIntelligenceTemporalRejectsUnanchoredClockTime),
("Story Intelligence temporal preserves yearless calendar date clues", StoryIntelligenceTemporalPreservesYearlessCalendarDateClues), ("Story Intelligence temporal preserves yearless calendar date clues", StoryIntelligenceTemporalPreservesYearlessCalendarDateClues),
("Story Intelligence temporal resolves yearless calendar dates from context", StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext), ("Story Intelligence temporal resolves yearless calendar dates from context", StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext),
("Story Intelligence temporal inherits previous morning when extraction is empty", StoryIntelligenceTemporalInheritsPreviousMorningWhenExtractionIsEmpty),
("Story Intelligence temporal inherits previous evening when extraction is empty", StoryIntelligenceTemporalInheritsPreviousEveningWhenExtractionIsEmpty),
("Story Intelligence temporal inherits previous night when extraction is empty", StoryIntelligenceTemporalInheritsPreviousNightWhenExtractionIsEmpty),
("Story Intelligence temporal inherits after midnight advancement", StoryIntelligenceTemporalInheritsAfterMidnightAdvancement),
("Story Intelligence temporal inherits chapter anchor when extraction is empty", StoryIntelligenceTemporalInheritsChapterAnchorWhenExtractionIsEmpty),
("Story Intelligence temporal stays unknown without context or extraction", StoryIntelligenceTemporalStaysUnknownWithoutContextOrExtraction),
("Story Intelligence temporal explicit date overrides previous context", StoryIntelligenceTemporalExplicitDateOverridesPreviousContext),
("Story Intelligence temporal preserves vague few days", StoryIntelligenceTemporalPreservesVagueFewDays), ("Story Intelligence temporal preserves vague few days", StoryIntelligenceTemporalPreservesVagueFewDays),
("Core import applies structural chronology to canonical scenes", CoreImportAppliesStructuralChronologyToCanonicalScenes), ("Core import applies structural chronology to canonical scenes", CoreImportAppliesStructuralChronologyToCanonicalScenes),
("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology), ("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology),
@ -7234,6 +7241,73 @@ static void StoryIntelligenceTemporalResolvesYearlessCalendarDatesFromContext()
Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Resolved yearless dates should retain the part-of-day label."); Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Resolved yearless dates should retain the part-of-day label.");
} }
static void StoryIntelligenceTemporalInheritsPreviousMorningWhenExtractionIsEmpty()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "morning");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context);
Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous resolved date.");
Assert(result.TimeModeName == "Exact Date", "Inherited exact dates should remain exact dates.");
Assert(result.RelativeTimeText == "28 Dec 1982, morning", "Empty extraction should inherit the previous part of day.");
}
static void StoryIntelligenceTemporalInheritsPreviousEveningWhenExtractionIsEmpty()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "evening");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context);
Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous evening date.");
Assert(result.RelativeTimeText == "28 Dec 1982, evening", "Empty extraction should preserve evening precision.");
}
static void StoryIntelligenceTemporalInheritsPreviousNightWhenExtractionIsEmpty()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context);
Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit the previous night date.");
Assert(result.RelativeTimeText == "28 Dec 1982, night", "Empty extraction should preserve night precision.");
}
static void StoryIntelligenceTemporalInheritsAfterMidnightAdvancement()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night");
var advanced = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(partOfDay: "night", crossesMidnight: true, evidence: "after midnight", confidence: 0.9m), context);
var inherited = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), advanced.NextContext);
Assert(advanced.StartDateTime == new DateTime(1982, 12, 29), "After midnight should advance from the previous scene date.");
Assert(inherited.StartDateTime == new DateTime(1982, 12, 29), "Empty extraction after midnight should inherit the advanced date.");
}
static void StoryIntelligenceTemporalInheritsChapterAnchorWhenExtractionIsEmpty()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context);
Assert(result.StartDateTime == new DateTime(1982, 12, 28), "Empty extraction should inherit chapter anchor when no previous scene chronology exists.");
Assert(result.TimeModeName == "Exact Date", "Chapter anchor fallback should produce the anchored date.");
Assert(result.RelativeTimeText is null, "Chapter anchor fallback should not invent a part of day.");
}
static void StoryIntelligenceTemporalStaysUnknownWithoutContextOrExtraction()
{
var context = new StoryIntelligenceTemporalContext(null, null, null);
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithoutTemporalExtraction(), context);
Assert(result.StartDateTime is null, "No extraction and no context should remain unknown.");
Assert(result.TimeModeName == "Unknown", "Unknown should be the final fallback when there is no chronology context.");
Assert(result.RelativeTimeText is null, "Unknown chronology should not invent display text.");
}
static void StoryIntelligenceTemporalExplicitDateOverridesPreviousContext()
{
var context = new StoryIntelligenceTemporalContext(new DateTime(1982, 12, 28), new DateTime(1982, 12, 28), "night");
var result = StoryIntelligenceTemporalResolver.Resolve(SceneWithTemporal(absoluteDate: "29 Dec 1982", partOfDay: "morning", evidence: "29 Dec 1982 morning", confidence: 0.95m), context);
Assert(result.StartDateTime == new DateTime(1982, 12, 29), "Explicit date should override the previous resolved date.");
Assert(result.RelativeTimeText == "29 Dec 1982, morning", "Explicit replacement should keep the current scene part of day.");
}
static void StoryIntelligenceTemporalPreservesVagueFewDays() static void StoryIntelligenceTemporalPreservesVagueFewDays()
{ {
var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null); var context = new StoryIntelligenceTemporalContext(new DateTime(1983, 10, 4), new DateTime(1983, 10, 4), null);
@ -7253,6 +7327,7 @@ static void CoreImportAppliesStructuralChronologyToCanonicalScenes()
Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(structuralScenes, result)", StringComparison.Ordinal), "Fresh core import should apply chronology after source text has canonical SceneIDs."); Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(structuralScenes, result)", StringComparison.Ordinal), "Fresh core import should apply chronology after source text has canonical SceneIDs.");
Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(recoveredScenes, existingBuild)", StringComparison.Ordinal), "Recovered core import should apply chronology when missing scene sources are repaired."); Assert(onboarding.Contains("await ApplyStructuralChronologyAsync(recoveredScenes, existingBuild)", StringComparison.Ordinal), "Recovered core import should apply chronology when missing scene sources are repaired.");
Assert(onboarding.Contains("ExtractStructuralTemporalEvidence(structuralScene.SourceText)", StringComparison.Ordinal), "Core import chronology should use SceneManuscriptSources-equivalent structural source text."); Assert(onboarding.Contains("ExtractStructuralTemporalEvidence(structuralScene.SourceText)", StringComparison.Ordinal), "Core import chronology should use SceneManuscriptSources-equivalent structural source text.");
Assert(onboarding.Contains("StoryIntelligenceTemporalResolver.Resolve(BuildStructuralTemporalScene(structuralScene), temporalContext)", StringComparison.Ordinal), "Cue-free structural scenes should still enter the rolling resolver so they can inherit chronology.");
Assert(onboarding.Contains("HasExistingChronology(canonicalScene)", StringComparison.Ordinal), "Core import chronology should preserve existing author-entered chronology."); Assert(onboarding.Contains("HasExistingChronology(canonicalScene)", StringComparison.Ordinal), "Core import chronology should preserve existing author-entered chronology.");
Assert(resolver.Contains("HasExplicitYear(clean)", StringComparison.Ordinal), "Temporal resolver should avoid current-year date parsing for yearless manuscript dates."); Assert(resolver.Contains("HasExplicitYear(clean)", StringComparison.Ordinal), "Temporal resolver should avoid current-year date parsing for yearless manuscript dates.");
} }
@ -7421,6 +7496,15 @@ static SceneIntelligenceScene SceneWithTemporal(
} }
}; };
static SceneIntelligenceScene SceneWithoutTemporalExtraction()
=> new()
{
Summary = new SceneIntelligenceSummary { Short = "The scene continues without a new time cue.", Confidence = 0.8m },
Setting = new SceneIntelligenceSetting { LocationName = "Pub", LocationType = "interior", Confidence = 0.8m },
TemporalAnalysis = null,
TimelineClues = []
};
static JsonElement SceneSchemaRoot() static JsonElement SceneSchemaRoot()
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema); => JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);

View File

@ -580,13 +580,7 @@ public sealed class OnboardingService(
continue; continue;
} }
var temporalScene = BuildStructuralTemporalScene(structuralScene); var resolved = StoryIntelligenceTemporalResolver.Resolve(BuildStructuralTemporalScene(structuralScene), temporalContext);
if (temporalScene is null)
{
continue;
}
var resolved = StoryIntelligenceTemporalResolver.Resolve(temporalScene, temporalContext);
temporalContext = resolved.NextContext; temporalContext = resolved.NextContext;
if (string.Equals(resolved.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase) if (string.Equals(resolved.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase)
|| (!resolved.StartDateTime.HasValue && string.IsNullOrWhiteSpace(resolved.RelativeTimeText))) || (!resolved.StartDateTime.HasValue && string.IsNullOrWhiteSpace(resolved.RelativeTimeText)))

View File

@ -95,6 +95,7 @@ public static class StoryIntelligenceTemporalResolver
temporal = InferFromLegacyFields(scene); temporal = InferFromLegacyFields(scene);
} }
var hasCurrentTemporalSignal = HasSceneTemporalSignal(temporal, scene);
var confidence = NormaliseConfidence(temporal?.Confidence); var confidence = NormaliseConfidence(temporal?.Confidence);
var contextDate = context.LastDate ?? context.ChapterAnchorDate; var contextDate = context.LastDate ?? context.ChapterAnchorDate;
var absoluteDate = ParseDate(temporal?.AbsoluteDate, contextDate) ?? ParseDate(fallbackText, contextDate); var absoluteDate = ParseDate(temporal?.AbsoluteDate, contextDate) ?? ParseDate(fallbackText, contextDate);
@ -103,12 +104,19 @@ public static class StoryIntelligenceTemporalResolver
var isDateDeterministic = absoluteDate.HasValue; var isDateDeterministic = absoluteDate.HasValue;
var relativeText = FirstNonBlank(temporal?.Evidence, temporal?.AbsoluteDate, scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay); var relativeText = FirstNonBlank(temporal?.Evidence, temporal?.AbsoluteDate, scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay);
var currentPartOfDay = CleanPartOfDay(temporal?.PartOfDay ?? scene?.Setting?.TimeOfDay ?? fallbackText); var currentPartOfDay = CleanPartOfDay(temporal?.PartOfDay ?? scene?.Setting?.TimeOfDay ?? fallbackText);
var inheritedPartOfDay = !hasCurrentTemporalSignal && context.LastDate.HasValue ? context.LastPartOfDay : null;
var displayPartOfDay = currentPartOfDay ?? inheritedPartOfDay;
var partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay; var partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay;
var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime); var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime);
if (!resolvedDate.HasValue && baseDate.HasValue) if (!resolvedDate.HasValue && baseDate.HasValue)
{ {
if (temporal?.CrossesMidnight == true) if (!hasCurrentTemporalSignal)
{
resolvedDate = baseDate.Value.Date;
isDateDeterministic = true;
}
else if (temporal?.CrossesMidnight == true)
{ {
resolvedDate = baseDate.Value.AddDays(1); resolvedDate = baseDate.Value.AddDays(1);
isDateDeterministic = true; isDateDeterministic = true;
@ -128,7 +136,7 @@ public static class StoryIntelligenceTemporalResolver
resolvedDate = baseDate.Value.Date; resolvedDate = baseDate.Value.Date;
isDateDeterministic = true; isDateDeterministic = true;
} }
else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || HasSceneTemporalSignal(temporal, scene))) else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || hasCurrentTemporalSignal))
{ {
resolvedDate = baseDate.Value.Date; resolvedDate = baseDate.Value.Date;
isDateDeterministic = true; isDateDeterministic = true;
@ -162,12 +170,12 @@ public static class StoryIntelligenceTemporalResolver
var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase) var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase)
? null ? null
: BuildRelativeText(start, exactTime, currentPartOfDay, relativeText, temporal); : BuildRelativeText(start, exactTime, displayPartOfDay, relativeText, temporal);
var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode); var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode);
var nextContext = context with var nextContext = context with
{ {
LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate), LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate),
LastPartOfDay = currentPartOfDay ?? context.LastPartOfDay LastPartOfDay = displayPartOfDay ?? context.LastPartOfDay
}; };
return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext); return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext);
@ -518,8 +526,7 @@ public static class StoryIntelligenceTemporalResolver
private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene) private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene)
=> temporal is not null => temporal is not null
&& (temporal.Confidence > 0 && (!string.IsNullOrWhiteSpace(temporal.AbsoluteDate)
|| !string.IsNullOrWhiteSpace(temporal.AbsoluteDate)
|| !string.IsNullOrWhiteSpace(temporal.ExactTime) || !string.IsNullOrWhiteSpace(temporal.ExactTime)
|| !string.IsNullOrWhiteSpace(temporal.Evidence) || !string.IsNullOrWhiteSpace(temporal.Evidence)
|| !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference) || !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference)