The Story Ledger
The third methodology doc, peer of docs/LOGIC.md and docs/READER-QA.md. The Logic Sweep owns causality/continuity QA; Reader-Proxy QA owns craft/comprehension QA; the Story Ledger owns the engine's record of what is true, and the detection of two facts that cannot both be.
It is a cold-ledger instrument only (LOGIC.md §10). It measures correctness. It cannot, and must never be asked to, judge whether the book is good.
1. The faculty it is
A storytelling engine needs three faculties. This one had built two:
| Faculty | Instrument |
|---|---|
| Composition — write a beat with the right context | DCM, ProseWriterRouter, ~25 enrichment services |
| Judgment — assess what was written | Logic Sweep, Reader-Proxy QA, StoryScope, publish-readiness |
| Memory — a trustworthy record of what is actually true, derived from the text and never from a paraphrase of it | This doc |
The defect that forced the third: a character's fabricated father (Dae-jung Seo) coexisted with
the climax establishing him as a construct with "no before" — across ~290 beats of a finished book
that had passed five clean logic sweeps. It had already spread into a weapon record and onto an
unrelated she/her character from a different book. Every instrument that missed it, missed it
for a mechanically explainable reason, and none of them were bad luck:
LogicSweepService.BuildClampedProsekeeps a book's head 50k and tail 50k above 100k chars and elides the middle. BCODA is ~1.9M. The sweep saw the father claim (head) and the climax (tail); the reveal that reconciles them sat in the elided middle.- The fact ledger could not represent the conflict:
ContinuityService.Upsertfires only on same predicate/different object, andfathervsoriginare different predicates. Not missed — unrepresentable. - Every other instrument windows or shards (range-scoped sweep subagents, a comprehension probe with a three-chapter recap), and none share verified state.
- With no bounded full-fidelity read path, every reader fell back to the one-line
Beat.Descriptionspine — an intent field with no binding to the prose at all until Phase 1 gave it one.
The common cause: nothing carried verified facts across a whole book at bounded cost. Every instrument truncates, windows or shards, then re-derives facts by paraphrase inside its own narrow view. Paraphrase is where "father" and "mentor" stop looking incompatible.
2. What the ledger is
ContinuityClaims — atomic (EntityId, Predicate, Object) triples, widened, not a new store.
The load-bearing columns beyond the triple:
| Column | Why it matters |
|---|---|
Snippet |
The mandatory verbatim span the claim was read from. This is what makes a claim evidence rather than an assertion, and it is what §5's anchor backfill matches against. |
SourceBeatId |
The exact beat. See §5 — this is the instrument's ceiling. |
SourceChapterId / SourceChapterNumber |
The older, coarser anchor. A 40-beat chapter and no further. |
BookSlug |
Null for entity-record and bible claims, which belong to no single book. |
Provenance |
§4. Did a human approve this, or did a model invent it? |
Status |
NEW / CONFIRMED / CANONICAL / CONTRADICTED / REJECTED / SUPERSEDED. |
ExclusionRuleId |
Which axiom flagged it, when the contradiction came from the ontology. |
3. The two detectors
3.1 Same predicate, different object (ContinuityService.Upsert)
The original mechanism. Numeric-safe: ObjectsMatch parses number words, so "fifty" and "50"
collapse into one claim and only a genuine arithmetic discrepancy contradicts. Three exemptions,
each answering a different question about the predicate:
| Exemption | Question it answers | Examples |
|---|---|---|
Volatile (IsVolatilePredicate) |
Does this value change as the story moves? | location_current, carrying, mood, companions |
Set-valued (IsSetValuedPredicate, 2026-09-04) |
Can this subject have many values at once? | ability, action*, knowledge*, possession*, skill, relationship* |
Same assertion (IsSameAssertion) |
Is this one fact recorded twice in different words? | "rebuilds bike" vs "rebuilt the bike" |
Age-by-book convention (SS-T17, 2026-10-15). A character record carries exactly one Age —
but a series spans years (BCODA ~2226, CYB1 ~2230), so the same person's age is a different,
equally true number in each book. That isn't a volatile fact (it doesn't reset or get superseded
the way location_current does) and it isn't set-valued (a person has one age, not many at once)
— it is a fact scoped to when, the one axis neither exemption covers. The fix lives in
NumericPredicates (age, tenure_years, career_length_years, zone_age_years,
duration_years, years): ContinuityService.FindConflict compares these specifically within
the same BookSlug (including two entity-record claims, which both carry BookSlug = NULL) and
never across books. A within-book inconsistency — two different ages for one character in one
book — still contradicts exactly as before; a book written four years later does not. The
character record's own Age column stays a single reference-point value (documented in
SCHEMA.md and character.schema.json); a book-specific age belongs here, as an age claim
scoped to that book, not in free-text Description.
Cardinality is not volatility, and conflating them cost real signal. A volatile predicate has
one value that changes over time; a set-valued predicate has many values that are all true
simultaneously. action is both; ability is only the second, which is why one list could not
cover both. Measured across the 1,316 live contradiction groups in BCODA/DWIACE/VATD before the
fix: 250 groups (950 rows) were pure cardinality — a character with two abilities filed as a
contradiction — and ~300 more were pure paraphrase. Corpus-wide, re-assessing the 3,776
CONTRADICTED rows under the corrected rules cleared 964 of them (26%).
A status is written once and never revisited, so every correction to these rules leaves the
existing rows stating a verdict the engine has already repudiated.
prose --continuity reassess [--apply] re-runs today's test over the CONTRADICTED population
and moves the unsupported ones back to NEW — deterministic, free, reversible, and the claims
themselves are untouched.
What survives the exemptions still is not a defect list. Reading a sample showed it is
dominated by complementary facets (augmentation_type: "four-armed combat specialist" against
"quadrupedal with four arms") and temporal states (beacon_status: "live" against "dormant
for 3 months, recovered"). Telling those from a genuine conflict is a judgement about the story,
and no string rule loose enough to merge them is tight enough to be safe — a rule that merges
complementary values is a rule that hides the next fabricated fact. So the discrimination is
bought rather than guessed: prose --ledger-adjudicate --slug <s> puts one narrow Sonnet call
per group in front of the prose each value was read from, behind the same quote gate as everything
else here. Compatible groups are cleared back to NEW; real conflicts keep their status and file a
LEDGER-CONFLICT finding. ~$0.03 per group, cached on the claim uids plus every anchor beat's
current TextHash, so an interrupted run resumes free. Only affordable at all because
§5's backfill took anchor coverage to 99.1% — an unanchored claim has no prose to
show, and the question cannot be asked.
Measured on BCODA's first 120 groups: 11 real conflicts — including a character's handedness recorded as both right and left, one fatal wound placed in two different parts of the body in consecutive beats, and a contract count stated as both 43 and 140 over the same eleven years.
The verdict field is emitted LAST, after the reasoning, and that is load-bearing. The first
version of the output contract asked for contradiction first and the explanatory note
afterwards, so the model committed to true/false before doing the analysis. Measured across
DWIACE + VATD on 2026-09-04: roughly one in ten filed conflicts carried a note arguing the
opposite of its own boolean — "complementary rather than contradictory", "no genuine
contradiction exists among the three sources" — answers it had talked itself out of and could not
revise, because the field was already spent. Reordering the contract (note → quote →
severity → contradiction) fixed both known cases at $0.03 each. Cached verdicts are keyed on a
PromptVersion so a change of this kind cannot be masked by the cache; that also means it
re-bills the corpus, which is what the single-group --predicate filter is for — validate the
change on a group you already know the answer for before paying for every book.
The paraphrase threshold is deliberately severe (subsumption, or ≥0.75 token overlap). The asymmetry is the reason: a false "same assertion" hides a real contradiction, while a false contradiction merely costs a triage decision. Complementary facets — "red hair in loose braid" vs "dark red hair" — are left for a human on purpose; deciding those is an author's call about the story, not a string comparison.
Its reach is narrow by construction, and LOGIC.md §3.4 describes it accurately as a numeric drift detector. It is not, and never was, a general identity-consistency check.
3.2 Different predicate, incompatible meaning (the exclusion ontology)
The gap the Dae-jung Seo defect fell through. PredicateExclusions holds disjointness axioms —
declarations that two claim shapes cannot both be true of one subject — and
PredicateExclusionService.GenerateCandidates pairs claims across predicates.
1. EXTRACT ContinuityExtractionService → claims w/ mandatory exact snippet (hash-gated)
2. COLLIDE (a) same-predicate/different-object → candidate
(b) predicate mutual-exclusion ontology → candidate
3. COLLAPSE one representative question per (entity, axiom)
4. ADJUDICATE one narrow Sonnet call per candidate — never on the whole book
5. GROUND verdict REJECTED unless its quote appears verbatim in the prose supplied
6. FILE FindingsService → "TUNEDREAD " findings, delete-then-recreate per book
Cost scales with the number of collisions, not with book length. That is what makes it runnable across a 46-book corpus where the clamped sweep could not be.
Step 3 is not an optimisation, it is the difference between one call and sixty-five. The
ledger records the same fact under many predicate names — BCODA's fabricated father appeared under
~13 father* predicates and the constructed origin under ~5, a cross product of 65 pairs asking
one question. An axiom is a statement about the families, so learning the answer once settles
the group.
Step 5 is the most important line in the system. An unquotable assertion about the text is exactly how "Dae-jung Seo" became canon; the instrument built to catch that must be incapable of committing it.
4. Provenance grades
One vocabulary across ContinuityClaims, Entities and CharacterRelationships — the question is
identical in each case.
| Grade | Meaning |
|---|---|
authored |
A human decided this. The only grade that is canon without qualification. |
observed |
Extracted from prose with a snippet that mechanically verifies against Beat.Text. |
inferred |
A model produced it without a verifying quote, or derived it. |
scaffolded |
Auto-created by entity scaffolding. Never canon. Candidate only. |
legacy-unknown |
Pre-existing rows (author ruling: grandfather, then flag the suspicious). |
This makes "show me everything in canon no human ever approved" a query
(prose --provenance-audit) instead of an archaeology project.
5. Beat anchors are the ceiling
Read this before trusting any result from the Tuned Read.
An unanchored claim cannot be adjudicated. TunedReadService.AdjudicateAsync refuses a pair
where neither claim carries a SourceBeatId, because adjudicating without prose means ruling on
two summaries — the paraphrase-only reasoning that invented the defect in the first place. A
temporal axiom (§6.2) additionally cannot order claims that have no position on
the book's clock, so it never fires at all.
Measured 2026-09-04: 24 of 24,758 live claims carried a beat anchor — 0.1%. SourceBeatId
arrived in Phase 2; every claim extracted before it was unanchored. So the instrument could not
produce a finding on 99.9% of the ledger no matter how good the ontology was — and it reported
that silence as a clean corpus.
The recovery is deterministic and free. Every claim already carries a verbatim Snippet, and the
engine already trusts snippet-in-beat containment as its quote-grounding gate;
prose --continuity anchor-beats reuses that exact test to recover the anchor each snippet
implies. Corpus-wide it took prose claims from 0.2% to 90.7% (9,968 rows).
It fails closed: a snippet matching two beats in its own chapter is left unanchored rather than assigned to the first hit. A wrong anchor is worse than none — it would cite innocent prose in a finding, hand the adjudicator the wrong carrier band, and key the verdict cache to unrelated text.
Two by-products worth reading as signals rather than as backfill misses:
- Stale snippets (937 corpus-wide) — the claim's own evidence no longer appears in its chapter. The beat was edited, split or deleted since extraction; the ledger is asserting something the prose stopped saying.
- Ambiguous snippets (81) — short or formulaic spans matching several beats. Re-extraction is the fix, never a guess.
prose --continuity stats reports anchor coverage for exactly this reason, and says outright when
zero anchors mean a clean result is meaningless.
The general lesson, which is not specific to this instrument: when an audit returns zero findings, establish that it was capable of a finding before calling the result clean. "Nothing wrong" and "could not look" are the same output.
6. The resolution gradient (the radio)
At each adjudication the reader holds three bands. The cost argument is that band 3 is lossless in facts and free of prose, so it spans a 500-beat book without growing with it.
| Band | Span | Content |
|---|---|---|
| Carrier | anchor ± 10 beats | Full verbatim Beat.Text |
| Near sideband | ± 40 beats | Beat.EventSummary — "what happened", hash-gated |
| Far band | the whole book | Accumulated ledger claims for the entity. No prose. |
Use Beat.EventSummary, never Beat.Description. EventSummary is observational and
hash-gated against Beat.TextHash; Description is authorial intent, written at ~30 words/beat,
and until Phase 1 had no binding to the prose at all.
6.1 Where axioms come from
Three sources, in increasing specificity:
- Built-in — logical/biological axioms, true in every universe (
UniverseId = Guid.Empty). - Canon-declared — a universe Bible law made machine-readable (GLMZ: "Iowan Behemoths are autonomous machines, NOT synthetic life").
- Learned — proposed by the adjudicator after it confirmed a real contradiction, landing as
proposedand generating nothing until a human approves it. This is how the ontology sharpens per incident instead of per patch.
Author the next axiom from evidence, not imagination. prose --continuity predicates reports
the ledger's real predicate vocabulary and --co-occur reports which families are actually held
by the same entity — an axiom can only ever fire on a pair that appears there. This is not
pedantry: the Phase 2 axioms named father while extraction had written father_name,
father_occupation and a dozen more, and a rule that silently matches nothing is
indistinguishable from no rule at all. Predicate alternatives match by equality (or an anchored
stem* family); object patterns match as case-insensitive substrings.
Check a candidate axiom against a hypothetical pair for free before approving it:
prose --exclusion-rules --test --predicate-a … --object-a … --predicate-b … --object-b ….
6.2 Temporal axioms
Some axioms are not statements about two predicates — they are statements about two predicates in an order. A dead character does not later act is the canonical one: expressed without the ordering it fires on every character who dies on the page, because a life that ends mid-book is the normal shape of a story.
PredicateExclusion.TemporalOrder = "b_after_a" means the pair is a question only when B's anchor
sits strictly later in reading order than A's. Rules:
- A temporal axiom is directional —
Symmetricis ignored for it, because swapping the sides asserts the opposite ordering, which is the opposite axiom. - Evaluated with no reading-order map available, a temporal rule is skipped, never treated as timeless — the timeless version is a far broader axiom than the one its author approved.
- The adjudication prompt overrides its own "two facts describing different moments are not a contradiction" guidance for these, which would otherwise clear every hit by construction.
- Equal positions fail: a death and an action recorded from the same beat is the death scene.
7. Runbook
# — populate and maintain —
prose --continuity extract --node <slug> # opt a book in (LLM cost, per chapter, hash-gated)
prose --continuity anchor-beats [--slug <s>] [--dry] # recover beat anchors — FREE, do this first
prose --continuity stats # counts, sources, and ANCHOR COVERAGE
# — survey before authoring an axiom (both free) —
prose --continuity predicates [--slug <s>] [--min N]
prose --continuity predicates --co-occur [--family <f>]
# — the axioms —
prose --exclusion-rules [--all] [--json]
prose --exclusion-rules --test --predicate-a <p> --object-a "…" --predicate-b <p> --object-b "…"
prose --exclusion-rules --propose --predicate-a <p> --predicate-b <p> --why "one sentence"
prose --exclusion-rules --approve --id <n> # a proposed axiom generates nothing until this
# — the read —
prose --tuned-read --slug <s> --dry # FREE: candidate counts + why an axiom was silent
prose --tuned-read --slug <s> [--max-candidates N] # one Sonnet call per uncached candidate
prose --ledger-adjudicate --slug <s> --dry # FREE: which same-predicate groups would be judged
prose --ledger-adjudicate --slug <s> [--max N] # ~$0.03/group; $6-8 for a big book
prose --ledger-adjudicate --slug <s> --predicate <t> [--entity <t>]
# ONE group, ~$0.03 — the way to re-judge after a
# change instead of re-billing the book to find out
# — is the instrument telling the truth about what it spent? —
prose --cost --history [--command <name>] [--take N] # the durable per-command calibration data
# — triage —
prose --continuity reassess [--slug <s>] [--apply] # re-judge CONTRADICTED under today's rules
prose --continuity stale-snippets [--slug <s>] [--by-book] [--supersede --confirm <n>]
prose --continuity search --text "<substring>" [--predicate-prefix <p>] [--live]
prose --continuity groups --slug <s> # N-way same-predicate groups, with ClaimUids
prose --continuity resolve --a <uid> --b <uid> --winner A|B|custom [--object "…"]
prose --continuity reject --claim <uid> [--note "…"]
prose --provenance-audit [--slug <s>] --universe <u>
prose --fact-ledger-refresh --slug <s> # re-run just the same-predicate check, free
--dry is the one to reach for first. It runs the entire deterministic half and spends
nothing. A candidate count in the hundreds means an axiom is too broad, and finding that out for
free is the difference between a useful instrument and a bill.
8. Where it plugs in
| Join | Mechanism |
|---|---|
| Publish-readiness gate 2 | LOGIC.md §9 condition 2 reads all three faces: CONTRADICTED claim rows (volatile predicates excluded), FACT-LEDGER findings, TUNEDREAD findings. A book whose ledger was never populated FAILS — not checked is not checked clean. |
| The battery | tuned-read is run directly (prose --tuned-read); the --audit-book --full battery that bundled it was deleted (BIBLE ADR-10) — cost is per uncached candidate, the same multi-call class as storyscope/swain/chekhov. |
| Generation loop-back | ProseWriterRouter injects prior TUNEDREAD findings as forward guidance, so the beat that would create the next contradiction is written knowing about the last one. |
| Write gates | CharacterRelationshipTargetCheck rejects a relationship row with an unresolvable or empty target; UnscopedUniverseWriteCheck fails closed on an ambiently-scoped write. |
| Caching | Adjudication verdicts key on (claim pair, axiom, both anchor TextHashes); extraction is hash-gated per chapter. An unchanged book costs nothing to re-read — including the verdicts that came back clean, or a clean book would cost the same as a broken one forever. |
| The obligation ledger (RFC 0013, 2026-09-15) | The sibling ledger for the defect class this one cannot represent (§9): an absence. NarrativeObligations records every promise the prose makes — quote + origin beat — when it is made, and the reconciler ages the ones never paid. Publish gate 6 reads it. --ground-entity-records downgrades a non-authored claim here to inferred when no beat entails the record text it came from. |
9. Honest limits
- The ontology catches only contradictions someone declared. It will not catch everything and must not be sold as if it will. Its value is converting a class of currently-undetectable defects into detectable ones, and getting better per incident rather than per patch.
- It is report-only, by law (LOGIC.md §4). Findings deliberately carry
no
Snippet/SuggestedFixpair, so no apply path can splice a machine "fix" over prose. Content defects are fixed one beat at a time by hand. - It is not a felt pass (LOGIC.md §10). Every claim can reconcile while the book reads dead.
- It is not a vote. A measurement, outside the SS-LAW-17 VotingGate, same exemption as the logic sweep and craft checklist.
- Entity-record and bible claims carry no beat, so a cross-source pair (a prose claim against an entity-record claim) is adjudicable only from the prose side.
- It cannot see an absence. Both detectors are pairwise over rows that exist; a promise the prose made and never kept produces no row and therefore no finding — BCODA's curtain-girl sat unrecorded for 475 beats while every claim about her reconciled. That class belongs to the Narrative Obligation Ledger, which records the promise when it is made.