/** * Consistency checks across entities and chapters. These catch the kinds of * contradictions that creep in as a manuscript grows: a character mentioned in * prose whose entity was deleted, or a relationship that only one side records. * See CHUNK #4 step 2.4. */ import type { Character, Entity, ProseMirrorDoc } from "./types.js"; import { collectMentionsInDoc } from "./backlinks.js"; export type RelationshipSymmetry = "symmetric" | "directed"; /** * Relationship kinds that should hold in both directions. If Ada lists "Bram: * sibling", then Bram should list "Ada: sibling" too. */ export const SYMMETRIC_RELATIONSHIPS = new Set(["sibling", "friend", "spouse", "partner", "family", "rival"]); export interface MentionIssue { kind: "orphan-mention"; chapterId: string; chapterTitle: string; entityId: string; /** The mention text, if any. */ name?: string | undefined; } export interface RelationshipIssue { kind: "relationship-asymmetry"; fromEntity: string; fromName: string; toEntity: string; toName: string; relationship: string; } export interface DeadCharacterIssue { kind: "dead-character-mentioned"; chapterId: string; chapterTitle: string; entityId: string; /** The mention text, if any. */ name?: string | undefined; } export type ConsistencyIssue = MentionIssue | RelationshipIssue | DeadCharacterIssue; /** * Find mention nodes that reference an entity id that no longer exists (e.g. * the entity was deleted). Returns one issue per offending mention. */ export function findOrphanedMentions( chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>, entityIds: Set, ): MentionIssue[] { const issues: MentionIssue[] = []; for (const ch of chapters) { const mentioned = collectMentionsInDoc(ch.doc); for (const entityId of mentioned) { if (!entityIds.has(entityId)) { issues.push({ kind: "orphan-mention", chapterId: ch.id, chapterTitle: ch.title, entityId, }); } } } return issues; } /** * Find characters who are marked `dead` but are still mentioned (by entity id) * in a chapter body. A dead character should not appear alive in the story. * * Bare-name mentions (no `entityId`) are ignored — a reference to a name that * is not yet an entity is an orphan-mention concern, not a dead-character one. */ export function findDeadCharacterMentions( chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>, entities: Entity[], ): DeadCharacterIssue[] { const dead = new Set( entities .filter((e): e is Character => !("fields" in e) && !("body" in e) && e.status === "dead") .map((c) => c.id), ); const issues: DeadCharacterIssue[] = []; for (const chapter of chapters) { const mentioned = collectMentionsInDoc(chapter.doc); for (const entityId of mentioned) { if (dead.has(entityId)) { issues.push({ kind: "dead-character-mentioned", chapterId: chapter.id, chapterTitle: chapter.title, entityId, }); } } } return issues; } /** * Parse the `relationships` array of a character into * `{ [targetId]: { kind, note } }`. */ export function parseRelationships(character: Character): Record { const out: Record = {}; for (const r of character.relationships ?? []) { out[r.targetId] = { kind: r.kind, note: r.note }; } return out; } /** * Find relationships recorded by one character but not reciprocated by the * other, for symmetric relationship kinds. Returns one issue per asymmetric * pair (reported once, from the recording side). */ export function findRelationshipAsymmetry(entities: Entity[]): RelationshipIssue[] { const characters = entities.filter((e): e is Character => !("fields" in e) && !("body" in e)); // index by id -> character (targetId is an entity id) const byId = new Map(); for (const c of characters) byId.set(c.id, c); const issues: RelationshipIssue[] = []; for (const c of characters) { const rels = parseRelationships(c); for (const [targetId, rel] of Object.entries(rels)) { const sym = rel.kind.toLowerCase(); if (!SYMMETRIC_RELATIONSHIPS.has(sym)) continue; const other = byId.get(targetId); if (!other || other.id === c.id) continue; // Does the other side record this character back with a symmetric kind? const otherRel = parseRelationships(other); const back = otherRel[c.id]; if (!back || !SYMMETRIC_RELATIONSHIPS.has(back.kind.toLowerCase())) { issues.push({ kind: "relationship-asymmetry", fromEntity: c.id, fromName: c.name, toEntity: other.id, toName: other.name, relationship: rel.kind, }); } } } return issues; } /** Run every consistency check and return the combined issue list. */ export function findInconsistencies( chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>, entities: Entity[], ): ConsistencyIssue[] { const entityIds = new Set(entities.map((e) => e.id)); const issues: ConsistencyIssue[] = [ ...findOrphanedMentions(chapters, entityIds), ...findRelationshipAsymmetry(entities), ...findDeadCharacterMentions(chapters, entities), ]; return issues; }