Phase 21N - Fix Entity Evidence Corruption

This commit is contained in:
Nick Beckley 2026-07-13 21:36:52 +00:00
parent 7001359281
commit a54da80ea2
6 changed files with 136 additions and 10 deletions

View File

@ -568,6 +568,8 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []);
var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]);
var colin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin mother waits nearby", "female instructor"], []);
var david = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("David", ["David the woman speaks first", "female examiner"], []);
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
@ -576,6 +578,10 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
Assert(colin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Colin should not inherit feminine presentation from neighbouring evidence, got {colin.Presentation}.");
Assert(colin.Warnings.Any(warning => warning.Contains("corrected Colin", StringComparison.OrdinalIgnoreCase)), "Colin correction should be visible in diagnostics warnings.");
Assert(david.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"David should not inherit feminine presentation from neighbouring evidence, got {david.Presentation}.");
Assert(david.Warnings.Any(warning => warning.Contains("corrected David", StringComparison.OrdinalIgnoreCase)), "David correction should be visible in diagnostics warnings.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity.");
@ -589,6 +595,10 @@ static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictS
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Document", "Pass certificate should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing.");
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []);
var adult = Metadata("char-family-relative", "MatureAdult", "Feminine");

View File

@ -67,6 +67,15 @@ public static class StoryIntelligenceIllustrationCompatibility
namePresentation.Value,
Presentations.Unknown);
var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max();
var warnings = new List<string>();
var guardedPresentation = ApplyPresentationGuard(
name,
scopedText,
presentation,
presentationConfidence,
warnings);
presentation = guardedPresentation.Value;
presentationConfidence = guardedPresentation.Confidence;
var explicitAge = ExplicitAgeBand(scopedText);
var relationshipAge = RelationshipAgeBand(name, scopedText);
var namedAge = NameAgeBand(name);
@ -89,7 +98,8 @@ public static class StoryIntelligenceIllustrationCompatibility
presentationConfidence,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
pronounPresentation.Confidence,
warnings);
}
public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item)
@ -259,13 +269,14 @@ public static class StoryIntelligenceIllustrationCompatibility
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
if (ContainsAny(text, "driving licence", "driver licence", "license", "licence")) return "Document";
if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document";
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " ford fiesta ", " fiesta ", " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
if (ContainsAny(text, "parcel", "package")) return "Parcel";
if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing";
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
@ -306,6 +317,9 @@ public static class StoryIntelligenceIllustrationCompatibility
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
public static string PresentationGuardWarning(string name, string attemptedPresentation, string correctedPresentation)
=> $"WARNING: Entity evidence guard corrected {name} presentation from {attemptedPresentation} to {correctedPresentation}; title/name hard evidence overrode leaked or unrelated evidence.";
private static EvidenceSignal ExplicitAgeBand(string text)
{
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m);
@ -369,6 +383,73 @@ public static class StoryIntelligenceIllustrationCompatibility
return EvidenceSignal.Unknown;
}
private static EvidenceSignal ApplyPresentationGuard(string name, string scopedText, string presentation, decimal confidence, ICollection<string> warnings)
{
var nameSignal = NameOrTitlePresentation(name);
if (nameSignal.Value == Presentations.Unknown)
{
return new(presentation, confidence);
}
if (presentation == Presentations.Unknown || presentation == nameSignal.Value)
{
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
if (HasExplicitContradictoryPresentation(name, scopedText, presentation))
{
warnings.Add($"WARNING: {name} has explicit contradictory presentation evidence: {presentation} conflicts with {nameSignal.Value} name/title evidence.");
return new(presentation, confidence);
}
warnings.Add(PresentationGuardWarning(name, presentation, nameSignal.Value));
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
private static EvidenceSignal NameOrTitlePresentation(string name)
{
var clean = CleanWords(name);
if (clean.Length == 0)
{
return EvidenceSignal.Unknown;
}
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|| clean is "graham" or "george" or "john" or "david" or "simon" or "kevin" or "colin")
{
return new(Presentations.Masculine, 0.98m);
}
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "beth" or "maggie" or "rosie" or "grace" or "annie" or "helen" or "elen")
{
return new(Presentations.Feminine, 0.98m);
}
return EvidenceSignal.Unknown;
}
private static bool HasExplicitContradictoryPresentation(string name, string scopedText, string attemptedPresentation)
{
var clean = CleanWords(name);
if (clean.Length == 0 || string.IsNullOrWhiteSpace(scopedText))
{
return false;
}
var text = CleanWords(scopedText);
var contradictoryTerms = attemptedPresentation == Presentations.Feminine
? new[] { "female", "woman", "girl" }
: attemptedPresentation == Presentations.Masculine
? new[] { "male", "man", "boy" }
: [];
return contradictoryTerms.Any(term =>
text.Contains($"{clean} is {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} was {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} as {term}", StringComparison.OrdinalIgnoreCase));
}
private static string HairColour(string text) => NormalizeHairColour(text);
private static string SkinTone(string text) => NormalizeSkinTone(text);
private static string HairLength(string text) => NormalizeHairLength(text);
@ -411,6 +492,15 @@ public static class StoryIntelligenceIllustrationCompatibility
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static bool StartsWithAny(string text, params string[] prefixes)
=> prefixes.Any(prefix => text.Equals(prefix, StringComparison.OrdinalIgnoreCase) || text.StartsWith(prefix + " ", StringComparison.OrdinalIgnoreCase));
private static string CleanWords(string value)
{
var chars = value.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string EntityAttributeText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
@ -529,7 +619,8 @@ public sealed record CharacterEvidenceProfile(
decimal PresentationConfidence,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence)
decimal PronounConfidence,
IReadOnlyList<string> Warnings)
{
public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m;
}

View File

@ -106,7 +106,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
{
var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var fallbackEvidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
var fallbackEvidenceJson = EvidenceTraceJson(observation, existing);
await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
observation.CharacterKey,
@ -132,7 +132,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
: $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}.");
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
var evidenceJson = EvidenceTraceJson(observation, existing);
var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
@ -240,6 +240,22 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing)
=> JsonSerializer.Serialize(new
{
character = observation.Name,
identityKey = observation.CharacterKey,
canonicalIdentity = observation.CharacterKey,
rawExtractedEvidence = observation.IdentityEvidence,
sceneEvidence = observation.TextEvidence,
relationshipEvidence = observation.RelationshipEvidence,
mergedEvidence = observation.TextEvidence.Concat(observation.RelationshipEvidence).ToArray(),
previousStoredEvidence = existing?.EvidenceJson,
finalMatcherEvidence = observation.Evidence,
evidenceSource = "Story Intelligence visualisation character state",
canonicalMergeReason = "Character state ID from canonical visualisation identity"
}, JsonOptions);
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
@ -482,6 +498,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
PreviousEvidence = existing?.EvidenceJson,
CurrentEvidence = evidenceJson,
DemandKey = DemandKey(observation.Evidence),
EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
DemandStatus = "Satisfied",
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
@ -520,8 +537,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions),
CurrentEvidence = EvidenceTraceJson(observation, null),
DemandKey = DemandKey(observation.Evidence),
EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
DemandStatus = demandStatus,
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
@ -584,7 +602,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
FallbackImageUrl = character.ImagePath,
FallbackUsed = true,
ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.",
CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions),
CurrentEvidence = EvidenceTraceJson(observation, null),
DemandStatus = "Not required for group entity",
ResolvedPresentation = "Unknown",
ResolvedAgeBand = "Unknown",
@ -627,10 +645,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence,
IReadOnlyList<string> Warnings,
CharacterEvidenceProfile Profile)
{
public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m;
public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0));
public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0, []));
public static CharacterEvidence From(CharacterEvidenceProfile profile)
=> new(
profile.AgeBand,
@ -643,6 +662,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
profile.RelationshipConfidence,
profile.NameConfidence,
profile.PronounConfidence,
profile.Warnings,
profile);
}

View File

@ -1683,7 +1683,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
=> new()
{
Id = "location-pending",
LibraryCode = "loc-domestic-desk",
LibraryCode = string.Empty,
Name = "Location not yet identified",
Label = "Awaiting scene location",
ImagePath = $"{FallbackRoot}/location-flat.svg"

View File

@ -155,6 +155,7 @@ public sealed class StoryIntelligenceExperienceImageResolution
public string? PreviousEvidence { get; init; }
public string? CurrentEvidence { get; init; }
public string? DemandKey { get; init; }
public string? EvidenceWarnings { get; init; }
public string? DemandStatus { get; init; }
public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; }

View File

@ -39,7 +39,11 @@
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Character.Name</strong><br /><span class="muted">@row.Character.Id</span></td>
<td><code>@(row.Character.ImageResolution.StableCode ?? row.Character.LibraryCode)</code><br />@row.Character.ImageResolution.Status</td>
<td>Age @Display(row.Character.ImageResolution.ResolvedAgeBand)<br />Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)</td>
<td>
Age @Display(row.Character.ImageResolution.ResolvedAgeBand)<br />
Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)<br />
<span class="text-warning">@Display(row.Character.ImageResolution.EvidenceWarnings)</span>
</td>
<td>@Revalidation(row.Character.ImageResolution)</td>
<td>@Display(row.Character.ImageResolution.ReasonChosen)<br /><span class="muted">@Display(row.Character.ImageResolution.IllustrationAllocationStatus)</span></td>
<td>