# Inkhaven 1.2.21 — The Facts book

_Theme: a first-class home for the **facts** a book's world
runs on — climate, geography, seasons, distances,
chronology — and the AI plumbing to write against them and
check prose for contradictions._

Early on, an author collects research in the **Notes** and
**Research** system books — loose plot notes, source
material.  But the *invariants* of the world (it's winter
in this hemisphere; the capital is three days' ride from
the coast; the war ended a generation ago) are a different
kind of thing: they're the ground truth every chapter must
stay consistent with.  1.2.21 gives them a dedicated
**Facts** system book and wires the AI to treat it as the
authoritative reference.

Four parts, one dependency chain (F.1 → F.2 → F.3 → F.4),
plus docs.

## Architecture seams (verified)

* **System books** — one source of truth: `SYSTEM_BOOKS`
  (`src/store/mod.rs:29`) + a `SYSTEM_TAG_*` const.
  `ensure_system_books()` auto-creates on open; user/system
  separation is already `system_tag.is_some()` everywhere.
  Notes/Research are the model (free-form prose).
* **AI scope** — the `AiMode` enum (`src/tui/inference.rs:45`):
  `None → Selection → Paragraph → Subchapter → Chapter →
  Book`.  `build_ai_mode_context()` (`app.rs:12558`) is the
  single function turning a scope into context text.
* **Chat pre-seed** — `chat_history: Vec<ChatTurn>`
  (`ChatTurn::User|Assistant`), replayed in
  `spawn_chat_stream` (`src/ai/stream.rs:35`).
* **Multilingual prompts** — the `*_default_prompt(lang_iso)`
  5-language pattern (`app.rs:~19716`) + `resolve_prompt` /
  `active_prompt_language` / `iso_from_long`.  Clone
  show-don't-tell.
* **Free chord** — `Ctrl+B Shift+X` is unbound; joins the
  Shift-family diagnostic cluster (Shift+T sdt, Shift+H
  rhythm, Shift+M rewrite).

## Phase F.1 — The Facts system book

Add `("facts", "Facts")` to `SYSTEM_BOOKS` (after
`research`) + `pub const SYSTEM_TAG_FACTS`.  Update the
doc-comments that enumerate the system-book set.  No
schema, no overlay — it's free-form prose like Notes (see
Decision 2).  `ensure_system_books()` creates it on the
next open; verify it renders in the tree and `inkhaven
doctor` counts it.  Foundation; tiny.

## Phase F.2 — Facts AI scope

`AiMode::Facts`, cycled **after `Book`** (so F9 reads
`… → Chapter → Book → Facts → None`, i.e. Facts sits
between Book and Selection in the wrap).  `next()` /
`label()` / quick-ref text.  A `build_ai_mode_context()`
branch that gathers every Facts-book paragraph
(`system_book_id(SYSTEM_TAG_FACTS)` → `collect_subtree` →
`get_content`) into a context block, formatted like the
Chapter/Book branch but headed `── Facts reference (N) ──`.

## Phase F.3 — Facts-scope chat pre-seed + fact-analysis prompt

When the Facts scope is active, pre-seed the conversation
so the author can interrogate their world's invariants:

* a multilingual **system prompt** —
  `facts_scope_system_prompt(lang_iso)` — that frames the
  model as a facts-analysis / fact-checking assistant
  grounded in the supplied Facts reference, and
* the Facts-book paragraphs loaded as the opening
  `ChatTurn` history so the model already "has" them.

5-language coverage (en/ru/de/fr/es), resolved the same way
as every other prompt.

## Phase F.4 — "Fact check" chord (`Ctrl+B Shift+X`)

`Action::FactCheck` (serde `editor.fact_check`) +
label/description/binding/dispatch; handler
`start_fact_check()` cloning the show-don't-tell flow.  Per
Decision 1, on fire it:

* **auto-locks the AI scope to Paragraph ("local")** — the
  check is about *this* paragraph, never a whole-book send;
* **grounds against the Facts book** — when Facts is
  non-empty, its content is injected as a ground-truth
  reference block ahead of the paragraph, so the model can
  flag contradictions ("the manuscript says snow, but your
  Facts say this region is tropical").  Empty Facts → a
  plain local fact-check.

Adds `fact_check_default_prompt(lang_iso)` (5 languages),
streams the verdict to the AI pane.  KEYBINDING.md row.

## Phase F.5 — Tutorial + notes

Tutorial 63 walking the whole loop (collect facts → Facts
scope chat → fact-check a paragraph), CONFIGURATION /
KEYBINDING entries, release-notes implementation log.

## Decisions

1. **The fact-check chord grounds against the Facts book
   and auto-locks scope to local (Paragraph).**  When you
   fact-check a paragraph, the effective scope is forced to
   the local paragraph and the Facts book is injected as
   the authoritative reference — so it checks *this* prose
   against *your* established facts, not the whole
   manuscript against general knowledge.  Falls back to a
   generic local check when the Facts book is empty.
2. **No prose overlay / theme colour for Facts.**  It's
   free-form prose like Notes, not an entity lexicon like
   Characters/Places, so Facts entries don't light up words
   in the manuscript.  (A "fact mention" overlay is an easy
   later add if wanted.)

## Out of scope (later)

* A deterministic (non-AI) contradiction scan of prose
  against structured facts — would need a Facts schema;
  this cycle keeps Facts free-form.
* A `Ctrl+Y`-style Facts lexicon-inference lookup chord.

## Implementation log

### F.1 — Facts system book (landed)

`("facts", "Facts")` added to `SYSTEM_BOOKS` after Research;
`ensure_system_books()` creates it on open with the canonical
order + protected/system_tag flags — no other wiring (user/system
separation is already `system_tag.is_some()` everywhere).  Documented
in the `SYSTEM_BOOKS` doc comment.  Verified `inkhaven init` creates
`books/facts/` and `inkhaven list` shows it.  Suite 1160.

### F.2 — Facts AI scope (landed)

`AiMode::Facts` cycles after `Book` (between Book and Selection in the
wrap), wrapping to None; `label()` / `next()` + F9 quick-ref + keybind
descriptions updated.  `SYSTEM_TAG_FACTS` const (first use).  One
cycle test.  Suite 1161.  (The scope's context delivery moved to the
seeded history in F.3 per the user's "visible history" choice.)

### F.3 — Facts-scope pre-seed + multilingual fact-analysis prompt (landed)

Per the user's call (visible pre-seeded history, not a hidden per-query
prefix): entering the Facts scope seeds the chat with the facts as a
visible user-turn prologue (marked `⟦Facts⟧` for idempotence) + an
assistant ack, framed by `facts_scope_system_prompt`.  Facts scope is
**sticky** — it doesn't auto-reset after a send, so the framing persists
across follow-ups.  `build_ai_mode_context` returns None for Facts (the
facts live in history); `gather_facts_chunks` + `seed_facts_session`
back it.  Three new multilingual fns (system prompt / seed intro / ack,
en/ru/de/fr/es) + a five-language test.  Suite 1162.

### F.4 — Fact-check chord `Ctrl+B Shift+X` (landed)

`Action::FactCheck` + label/description/binding/dispatch; handler
`start_fact_check` cloning the show↛tell one-shot.  Decision 1: locks
the scope to the local paragraph and grounds against the Facts book
(`gather_facts_chunks` → reference block), under
`facts_scope_system_prompt`; the instruction is the multilingual
`fact_check_default_prompt`, resolved Prompts-book → prompts.hjson →
embedded.  Empty Facts → generic local check.  KEYBINDING.md row;
prompt added to the five-language test.  Suite 1162.

### F.5 — Tutorial + notes (landed)

Tutorial 63 (the full loop: collect facts → Facts scope chat →
fact-check chord), indexed in Tutorials/README.md.  This log.

## Facts — follow-ups

Selected after the 1.2.21 build review (the user's call); captured here
for a later cycle.  Expands and supersedes the brief "Out of scope"
list above.  Ordered by leverage.

### FF.1 — Semantic fact-search modal (explicit RAG)

**Problem.**  Both the Facts scope seed (F.3) and the fact-check chord
(F.4) inject **every** Facts paragraph, every turn.  For a richly-built
world (hundreds of facts) that overflows the context window and burns
tokens.

**Design (user's call — explicit, not auto-RAG).**  A new chord opens
a **Facts search modal**: type a query → semantic search over the
Facts-book paragraphs → ranked matches → feed only the matching facts
to the AI as grounding.  User-driven retrieval rather than
dump-everything.

* Reuse the existing embeddings + HNSW vector index — the
  similar-paragraph picker (`Ctrl+V S`, Tutorial 16) is the closest
  model for the modal + ranked list.
* Subsumes the earlier "relevant-facts lookup" idea and the "token
  controls" idea — this *is* the relevance filter, made interactive.
* **Open decisions for impl:**
  * How results feed the AI — pick-and-send selected entries, or
    auto-send top-K above a similarity threshold (offer both: Enter =
    send selected, a key = send all shown).
  * Integration with the F.3 scope seed: for a large Facts book, the
    Facts scope could seed only the top-K relevant to the open
    paragraph (or defer entirely to this modal) instead of seeding
    everything.  Keep the full seed for small books.
  * Pick a free chord (verify at impl time) — candidate `Ctrl+B
    Shift+S` (S for Search facts); confirm it's unbound first.
  * Needs a public `Store::embed(text)` accessor (also wanted by the
    deferred C.3.b continuity work).

### FF.2 — Whole-manuscript fact-check scan

A `fact-contradiction` `doctor --scan` class: walk every chapter /
paragraph, check each against the Facts book (RAG-filtered per FF.1 to
keep cost sane), and report Info-severity findings — the batch
companion to the one-paragraph `Ctrl+B Shift+X` chord.  `--json` for CI
gates, like the other narrative scans.  AI-backed per paragraph, so
note the cost; sampling / RAG-filtering / a cache mitigates it.  See
the structure note below — a deterministic pass becomes possible if
facts gain optional structure.

### FF.3 — Fact extraction from prose

The reverse direction, mirroring `inkhaven continuity extract`: an AI
pass that reads the manuscript and proposes the world-invariant facts
it has *implicitly* established ("'three days' ride' appears twice —
add as a fact?"), presented for review and accept-into the Facts book.
Solves the cold-start problem (an empty Facts book does nothing).

* CLI `inkhaven facts extract` + a TUI review/accept flow (y/N per
  candidate, like `inkhaven recover`).
* Dedupe candidates against existing Facts entries (stem / embedding
  similarity) so a re-run doesn't re-propose what's already there.
* Approach sketch: chunk the manuscript by chapter, prompt the model
  to emit candidate facts as a structured list (category + statement +
  the source paragraph), collect, dedupe, present.

### FF.4 — Tier 2 bundle

* **FF.4a — Freshness / re-seed.**  The F.3 seed snapshots the facts
  when you enter the scope; editing the Facts book mid-session leaves
  the chat stale.  Re-seed on Facts-book change (or a manual "reload
  facts" key); the `⟦Facts⟧` marker already makes the seed detectable
  for replacement.
* **FF.4b — Force Local inference for fact-checking.**  Fiction facts
  must not be overridden by the model's real-world knowledge
  ("equatorial regions *can* get snow at altitude…").  The Facts scope
  + chord should default to `Local` (grounded only in the supplied
  facts), not inherit the F10 `Full` toggle.  Possibly a config knob
  (`editor.facts_force_local`, default true).
* **FF.4c — Facts scaffolder + genre templates.**  Like the Language
  book's `b`-scaffold: seed a starter skeleton (Climate, Geography,
  Calendar / Chronology, Politics, Economy, Tech / Magic rules, …),
  genre-aware.  Turns an empty Facts book into fill-in-the-blanks.
* **FF.4d — Actionable fact-check output.**  The verdict is plain text
  today.  Make it navigable — jump to the contradicting *fact* and to
  the offending *phrase* in the paragraph (reuse the diagnostics
  navigation pattern, Tutorial 27); optionally structured markers the
  AI pane can parse.

### FF.5 — Facts ↔ source linking + backlinks

Link a fact to where it came from and what depends on it, reusing the
existing paragraph-link machinery (`linked_paragraphs`, `Ctrl+V
A/I/L/K`, wiki-links — Tutorial 19):

* fact → its **Notes / Research** source (provenance), and
* fact ↔ the **manuscript paragraphs** that rely on it (backlinks).

Then "this fact changed" can surface the N dependent paragraphs to
re-check — the natural pairing with FF.4a freshness and the FF.2 scan.

### FF.6 — Status-bar facts chip

A quiet `facts: N` count chip, toggled by config like the worldbuilding
glossary chip (`editor.show_glossary_chip`) and the POV chip — so the
Facts book's presence + size is visible at a glance.

### Cross-cutting decision — free-form vs optional structure

Facts are free-form prose today (Decision 2), which is right for
collection.  But FF.2 (a cheap *deterministic* contradiction pass) and
FF.4d (structured, navigable output) get much sharper if a fact can
carry **optional** light structure — e.g. a leading tagged line
(`#climate equatorial, no winter`) the way Threads paragraphs front
HJSON.  Keep prose the default; consider an optional structured form.
Decide this **before** building FF.2, since it shapes the scan.

**Parked** (not selected this round): an internal-consistency check of
the Facts book against itself, and multi-book / series-shared facts.

### FF.1 — Facts semantic-search modal (landed)

Decisions confirmed: send = **targeted Facts chat**; **multi-select**
(Space); chord **Ctrl+B Shift+S**.  Two findings simplified it: (a)
`Store::search_text(query)` already embeds the query, so **no public
`Store::embed` was needed**; (b) semantic search embeds per query, so
the modal searches **on Enter**, not per keystroke (two-phase to dodge
the Space-vs-space conflict — Space is a query char in edit mode, a
mark toggle in results mode).

* Prep (own commit): extracted `apply_facts_seed(chunks)` (the shared
  re-seed core — removes any prior `⟦Facts⟧` seed, prepends the new
  prologue, switches to the sticky Facts scope) and `facts_chunks_for(ids)`.
* `Modal::FactsSearch { input, entries, cursor, marked, browsing }`.
  `open_facts_search` (`Ctrl+B Shift+S`), `search_facts` (whole-project
  `search_text` post-filtered to the Facts subtree via `collect_subtree`,
  reusing the similar-paragraph `SearchHit` path but seeded from the
  typed query), `run_facts_search`, `facts_search_handle_key`
  (two-phase), `facts_search_send` (builds chunks for the marked ids —
  or the cursor's — and calls `apply_facts_seed`, then focuses the AI
  prompt).  Render `draw_facts_search_modal` (query box + ranked rows
  with `[x]` marks + mode-aware footer).  Wired both dispatch sites
  (key + render) + `Action::SearchFacts` + label/description.
* KEYBINDING.md row.  Suite 1162, clean build.

Carries forward (not in FF.1): the modal could also become the seed
path for a *large* Facts book entering the F9 scope (seed top-K
relevant instead of all); left as-is for now.

### FF.2 — whole-manuscript fact-check scan (landed)

Resolved the flagged fork: `doctor --scan` classes are deterministic
(no LLM, run in the health monitor), so an AI-backed fact-check ships
as a standalone command (`continuity`/`tension` pattern), NOT a
ScanClass.  Free-form facts work — the LLM does the semantic check; the
deterministic doctor-class would need structured facts (still parked).

`inkhaven facts scan` walks every user-book chapter, RAG-filters the
Facts book to the entries relevant to that chapter (whole-project
`search_text` post-filtered to the Facts subtree, full bodies read +
flattened to plain — keeps each prompt small for a big Facts book), and
asks the LLM (one call per chapter, multilingual via the woven
`language`) to emit `claim | fact | detail` contradictions.  Findings →
`<project>/.inkhaven/facts_scan.json`.  `facts list` re-dumps; `--json`
on either emits the report for CI gates.  Cheap preconditions (Facts
book present + non-empty, chapters present) are checked before the LLM
is required, so a project with nothing to check fails clearly without
an API key.

New pure `crate::facts_scan` (FactFinding / FactScanReport /
`parse_findings` + sidecar round-trip, 5 tests) + `crate::cli::facts_scan`
(the command).  Tutorial 63 gains a scan section.  Suite 1162 → 1167.

### FF.3 — fact extraction from prose (landed)

`inkhaven facts extract` solves the cold-start: an AI pass (mirroring
`continuity extract`) walks every user-book chapter, proposes the
world-facts the prose implicitly establishes (`category | statement`),
dedupes them — against the existing Facts book AND across chapters —
via stemmer-normalised token-set Jaccard (so inflected restatements
collapse), then runs an interactive `y/N/a=all/q=quit` review.  Accepted
candidates are written into the Facts book as paragraphs
(`store.create_node` + `update_paragraph_content`, hierarchy reloaded
per insert for fresh ordering).  `--dry-run` lists without adding;
`--yes` accepts all; `--provider` overrides the LLM.  Cheap
preconditions (Facts book present, chapters present) are checked before
the LLM is required.  The Facts book may be empty (the whole point).

New pure helpers in `crate::facts_scan` (`FactCandidate`,
`parse_candidates`, `normalise_tokens`, `near_duplicate`) + 2 tests.
`FactsCommand::Extract { provider, yes, dry_run }`.  Tutorial 63 gains a
bootstrap section.  Suite 1167 → 1169.

### FF.4 — Tier-2 bundle (a/b/c landed; d deferred)

* **FF.4a — freshness re-seed (landed).**  `seed_facts_session` now
  compares the current Facts book against the existing `⟦Facts⟧` seed
  payload: unchanged → "active (unchanged)"; changed (you edited /
  extracted facts since) → re-seed via `apply_facts_seed` and report
  "refreshed".  Re-cycling F9 to Facts keeps the session current; no
  new chord.
* **FF.4b — force Local (landed, mostly already true).**  Discovered
  the clamp was already in place: F10's Local/Full only selects the
  generic system prompt, and Facts scope + the chord override that with
  `facts_scope_system_prompt` (which says treat facts as ground truth,
  don't invent, say unspecified) — so fact-checking never uses the Full
  prompt.  The only gap was cosmetic: the `infer=` chip now reads
  `Local` in Facts scope, honest about the clamp.  No redundant config
  knob (the prompt is the clamp).
* **FF.4c — Facts scaffolder (landed).**  `inkhaven facts init` seeds
  the starter categories (Climate / Geography / Seasons / Chronology /
  Culture / Rules) as fill-in-the-blank paragraphs, idempotent (kept on
  re-run unless `--force`).  Reuses the FF.3 `create_node` +
  `update_paragraph_content` path.  (Genre-aware skeletons deferred —
  that pairs with a genre-packs theme, not this cycle.)
* **FF.4d — navigable verdict (deferred).**  Making the fact-check
  verdict jump-to-claim / jump-to-fact is a standalone navigable-UI
  feature (parse the streamed verdict, render a list, wire cursor jumps
  via the diagnostics pattern) — larger than the other three and the
  streamed verdict is already functional.  Left as its own follow-up.

Tutorial 63 updated (init scaffolder, freshness, the Local clamp).
Suite 1169 (FF.4a/b are behaviour + a render tweak; FF.4c is CLI —
verified by smoke test: init creates 6 categories, idempotent re-run).

### FF.5 — Facts ↔ source linking + backlinks (landed)

Found (like FF.4b) that the mechanics already work for free: a Fact is
just a paragraph, the `linked_paragraphs` wiki-link model is
book-agnostic, and `Ctrl+V A/I/L/K` link cross-book to/from Facts with
no system-book filter — so provenance (Fact → Notes/Research) and
dependents (manuscript → Fact, seen via the `Ctrl+V K` backlinks on a
Fact) already function.  The real gap was discoverability, so the core
deliverable is documentation (Tutorial 63 gains a "tie a scene to the
facts it depends on" section).

The one new code touch makes the links *do something* in the Facts
workflow: the fact-check chord (`Ctrl+B Shift+X`) now leads with the
open paragraph's explicitly-linked facts in a labelled "Facts this
scene is linked to" block (the model weights contradictions with them),
then includes the rest — **additive**, so coverage never drops.  New
`facts_paragraph_ids` helper; `gather_facts_chunks` refactored onto it.
Status notes the linked count.  Suite 1169.

Deliberately NOT added (the existing generic system covers them or
they're out of scope): a system-book filter on the link picker (we WANT
cross-book), link-type annotations, deletion cascade (generic, handled
by reindex + load-time filtering).

### FF.4d — navigable fact-check verdict (landed)

The deferred Tier-2 item.  The fact-check chord (`Ctrl+B Shift+X`) now
emits its verdict as parseable `claim | fact | detail` lines
(`fact_check_default_prompt` realigned to the scan's format, 5 langs),
and `pump_inference` parses the completed verdict into a navigable
`FactCheckNav { target, findings, idx }` (via `facts_scan::parse_findings`).

New chord `Ctrl+B Shift+J` (`Action::NextFactFinding`) cycles the
findings: each press moves the editor cursor to the next flagged claim
in the open paragraph (`jump_cursor_to_text` — case-insensitive search →
`CursorMove::Jump`, best-effort since the model may paraphrase) and
shows the violated fact + explanation on the status bar.  Findings are
tied to the checked paragraph (the `target`); switching paragraphs
prompts a re-run.  Tutorial 63 + KEYBINDING.md updated.  Suite 1169.

With FF.4d done, the full FF list (FF.1–FF.6) + genre-aware scaffolds
are complete.
