Files
PubScript/packages/core/src/consistency.ts
T
Schwitzz 301e842c5c feat(entity): Chunk #4 remaining items — consistency UI, context rail, palette search, expanded seed & DATA_MODEL
- Consistency tab in EntitiesView surfacing findInconsistencies() results
- ContextRail + MentionPopover wired into WritingView for entity context/hover
- CommandPalette indexes entities and chapters for cross-entity search
- link field type for entity references in character fields
- findDeadCharacterMentions consistency check + tests
- Expand seed with a location, note, and a natural Elias Vell mention so the
  dead-character-after-death case is actually exercised
- Author docs/DATA_MODEL.md documenting core schemas, types, storage contract
- Rename CharacterMention id attr -> entityId to match core backlinks/consistency
2026-10-26 12:02:03 -05:00

167 lines
5.3 KiB
TypeScript

/**
* 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<string>,
): 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<string, { kind: string; note: string }> {
const out: Record<string, { kind: string; note: string }> = {};
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<string, Character>();
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;
}