Skip to content

KG Readable Representation — narrative biographies from claims — Design Spec (#TBD)

Milestone: kg-readable-representation

Design-led (Testing Constitution). Status: APPROVED — 2026-09-10. Tags: [OK] built · [PARTIAL] exists, extend · [MISSING] not built.

Scope boundary: this specs HOW the readable representation is BUILT — a deterministic, background, backend NLG pipeline over the existing KG. It is NOT the document inspector (kg-entity-inspector.md), which is the display interface; that surface consumes what this produces. Two hard constraints: (1) NO LLM generation anywhere in the path — rendering is 100% deterministic from stored claims (the current narrative_v1 LLM prompt “makes it up” and is the thing being replaced); (2) any language, not just Spanish/English.

Why this spec exists

The KG is truth-bearing but reads like a database: dot-separated triples, review tables, and (the least useful view) a hairball graph. A reader — a historian, the person whose life the records describe — wants readable English: a biography ordered the way a writer would order it (chronologically, or grouped by source), with every statement still carrying its evidence. The goal is programmatic but writerly prose that never fabricates and never hides uncertainty.

This is a backend text-generation concern (claims + sources → ordered prose), which makes it deterministic and fully unit-testable without a GUI — the right first target while the app build is blocked. It iterates on what already exists; it does not replace it.

What already exists (iterate, don’t replace)

  • fichero-server/src/fichero_server/knowledge/paragraph.py — deterministic claim→prose with ParagraphStyle (narrative / list / footnoted), superscript citation markers, _claim_sentence, _group_claims (merges mergeable claims), and subject/verb/object ordering via knowledge/_common.py (order_statement_parts, render_statement).
  • resources/prompts/catalogue/narrative_v1.md — an LLM narrative prompt. Corrected 2026-09-18: this is NOT a “polish layer” this spec’s no-LLM path builds on — it is the very thing kg.read.no-llm bans from the render path, live today at the /bio route (see kg.read.no-llm, [BROKEN]). Kept here as a historical pointer, not an endorsed layer.
  • api/routes/entity/inspector.py, entity/entities.py — entity-scoped read surfaces.
  • Swift Models/ClaimLine.swift — the app’s clause rendering (recent fix: trim run-on person spans / entity display names read naturally — commits 444dfc6c0, 9eeef2114).

Theoretical grounding (this is an old, respectable practice)

Historians have rendered archives as readable text for two centuries; the genres have unfashionable names but each is a deterministic text rendering of a graph, and Fichero can generate them all: - regest / calendar — one dated paragraph per document, in order. - prosopographical entry — a person’s assertions gathered; a biography is this done well. - gazetteer entry — a place’s assertions gathered. - index nominum / concordance — name/term → attestations.

The rigorous model underneath is Bradley & Short’s factoid model: you never store “facts about a person,” you store assertions a source makes — each with date, place, role, citation. That is already what the extraction pipeline produces (a KnowledgeClaim is a factoid: subject/verb/ object + source anchor + confidence), which is why this is cheap for Fichero — the data is right. Precedents: Gramps narrative reports (GEDCOM→sentences+citations, decades old); Lsjbot (millions of template articles — shows both the scale AND the flatness you get if you stop at templates); Abstract Wikipedia + Grammatical Framework (Wikidata→multilingual via functions/lexemes — the serious bar).

Intent (the design)

Given an entity (or a set), render a biography (or any genre above): readable prose composed from its claims/factoids, under a chosen ordering and grouping, with citation markers linking every statement to its source anchor, and with uncertainty shown, not hidden (low-confidence or contradicted claims are marked, hedged, or sectioned — never silently asserted). Composition is 100% deterministic — no LLM in the path, ever — computed in the background (like embeddings) and stored, not generated on demand in a web request. The rendered prose is in the language of the SVO/claim itself — i.e. the language of the source the factoid was extracted from (a claim from a Spanish document renders Spanish, a French source French); the representation is multilingual because the sources are, so realisation must be language-aware per claim, not a fixed language pair.

A question a reader will have: who writes the sentence? (B8, answered 2026-09-19)

Neither an LLM nor spaCy writes the sentence you read. The SENTENCE is composed deterministically from the stored subject, verb, and object — no model runs at render time; see “Composition is 100% deterministic” above. The TRIPLES themselves (the subject/verb/object this composer reads) were extracted earlier, upstream, by either an LLM workflow or by spaCy, depending on which extraction path produced the claim — that provenance is a fact about the CLAIM, not about the sentence written from it. A better paragraph than what the app shows today already exists in the engine — render_entry with aggregation (landed 424ae4223) — but the app does not call it yet; see kg.read.lives-in-reader and kg.read.biography below for that gap.

Rulings (creative director, 2026-09-18) — the claim as the unit, re-centred, in the Reader, source-language-only

Four rulings, following a Fabel review (fabel-kg-readable, revision 2) that traced every live renderer against the code. Central finding the spec was silent on: there are FIVE renderers today, not one — the engine’s readable.py pipeline (2026-09-10, stages 1/2/3/5/6 built, 24 tests) has zero callers outside its own tests; the biography a maintainer actually sees comes from a separate ten-line Swift loop (EntityDigestContent.biographySentences, EntityDigestView.swift:648-675) that puts the page’s entity in front of the first claim and the English word “they” in front of every other claim, regardless of who the claim is really about. That one fact explains most of the four standing complaints (not clear / hard to edit / wrong subject / not multilingual) — see kg.read.one-renderer below.

  1. The unit of editing is the claim, never the prose. Acting on a rendered sentence opens the claim behind it (subject/verb/object/date) for correction, AND brings up the SOURCE the claim came from — the page image or document — with the claim’s passage highlighted, in one gesture. The sentence re-renders from the corrected claim. The correction is audited, survives re-extraction, and is training data. See kg.read.edit-unit-is-the-claim, kg.read.sentence-opens-source-highlighted.
  2. Sentences are re-centred on the page’s entity. On an entity’s own page, that entity is the grammatical subject of every sentence, including claims where the source made it the object — this needs a per-verb INVERSE phrasing table, per language. A verb with no inverse entry keeps the claim’s TRUE subject rather than a false one. See kg.read.recentred-on-page-entity.
  3. The paragraph lives in the Reader pane when an entity (or claim) row is selected; a sentence click drives the Source pane to the page with the span highlighted; the Inspector keeps curation (statements list, merge, aliases, history). See kg.read.lives-in-reader.
  4. Source language only, for now. The paragraph renders in the document’s own language; an entity evidenced in two languages gets sentences in each source’s language, never a gloss or cross-language realisation — record that as deliberately deferred, not a gap to fix now. See kg.read.source-language-only.

Today’s complaints, restated as the problem these rulings answer: not clear; hard to edit; often the wrong subject; not multilingual.

Behaviors

A. Rendering architecture — one renderer, not five

  • kg.read.one-renderer[BROKEN] (#4832) there are FIVE renderers touching KG claims today, and the spec previously named none of this: (A) EntityDigestContent .biographySentences (EntityDigestView.swift:648-675) — LIVE, the biography the maintainer sees, mounted at DocumentInspector.swift:207 and DocumentInspectorEntitiesTab+Rows.swift:58; (B) ClaimLine.text (ClaimLine.swift:41-61) — LIVE, the “Source Annotations” rows under the biography, and the one renderer that gets the subject right on the same screen; (C) a JS claimSummary in document_view.html:971-976 — LIVE, the document-level KG web pane; (D) paragraph.py’s render_paragraph_claims — route POST /api/kg/render/paragraph exists, but the Swift wrapper renderKGParagraph has ZERO callers; (E) the readable.py pipeline (stages 1/2/3/5/6, 2026-09-10, 24 tests) — ZERO callers anywhere outside the file and its own tests. Every kg.read.* behavior below that cites a readable.py test is describing a module NOBODY SEES — retagged honestly below as “[PARTIAL] — built and tested in the ENGINE, not wired to any view” rather than the tag its test count alone would suggest. Target state: ONE renderer, in the engine (readable.py’s successor, the entry composer below); the app draws what it is given; (B)/(C)/(D)’s bespoke loops retire once the Reader rendition (kg.read.lives-in-reader) replaces them.
  • kg.read.statements-join-as-sentences[PARTIAL] (#4852) FIXED in e71bb070b and open until the maintainer confirms it live: the Inspector’s text digest is built as one whole sentence per claim from the claim’s own subject, verb and object, joined with a space, so “.;” cannot occur; a claim without a complete triple is skipped. Pinned: fichero/Tests/Unit/general/Views/Inspector/KGSectionGroupingTests.swift (suite KGSectionGroupingTests). What follows describes the defect as found. joining several claim statements about one entity into a list must read as sentences, never as fragments glued with punctuation that collides with the text’s own. Verified on disk: the live source is KnowledgeGraphInspectorSection.digestMarkup(displayName:contexts:) (KnowledgeGraphInspectorSection+Grouping.swift), which prints the entity’s name in bold and then its contexts joined with a literal "; ". The contexts are mixed in kind: the first is a verb-phrase fragment (“es llamado Antonio (nombre propio)”), the rest are whole sentences that repeat the subject and already end in a full stop — producing “Antonio Mondragon es llamado Antonio (nombre propio); Antonio es clasificado como Negro (esclavo).; Antonio es esclavo de…”. Two defects, then: a semicolon join over text that is already punctuated, and contexts that are sometimes fragments and sometimes sentences. Fix direction: whole sentences only, joined with a space, never punctuation the text already carries. EntitySourceGroupsView has a similar "; " join but is NOT live: its only mount is EntityDetailView, which nothing mounts, so the unreachable framing in kg/kg-entity-inspector.md holds (→ #4828). Longer term this view should render from render_entry/the entity-readable route (kg.read.biography), which already returns whole sentences with offsets and claim ids — one renderer, not six.

B. Ruling 1 — the claim is the unit of editing

  • kg.read.edit-unit-is-the-claim[PARTIAL] (#4833, BUILT in f58345950/69fba6090; stays open until the maintainer has used it) each sentence in the Inspector’s digest AND the biography now carries an explicit “[Edit]” run beside it (EntityDigestView.swift:264-468, "[Edit]" a real accessible link, not a bare glyph, reachable the same way by keyboard/ VoiceOver as the reveal link) that opens the one InlineClaimEditor in a popover — the three gaps this line previously named are now closed: (a) the missing edit affordance is built (the “[Edit]” run itself); (b) the subject round-trip is fixed — the subject is now an entity picker sending subject_entity_id only when it changed, and the engine (69fba6090) updates entity_ids, takes the new entity’s canonical name, and regenerates the sentence through compose_claim_sentence (the one composer, extracted from the extraction path so both call it); (c) the editor gained time_start/time_end/time_precision, threaded through ClaimStore.patch and EntityService.patchClaim. Not covered: the claim card’s and EntityKindBlock’s editor mounts still pass no update callback (save correctly, refresh only on the stream’s echo), and the full EditClaimSheet still calls the action directly rather than through this same path. Pinned: InlineClaimEditorTests (fichero/Tests/Unit/general/Views/Library/InlineClaimEditorTests.swift), ClaimStoreTests (fichero/Tests/Unit/general/Models/ClaimStoreTests.swift), KnowledgeGraphInspectorSectionTests (fichero/Tests/Unit/general/Views/Inspector/KnowledgeGraphInspectorSectionTests.swift), EntityClickthroughTests (fichero/Tests/Unit/general/Views/Inspector/EntityClickthroughTests.swift).
  • kg.read.editor-saves-through-the-audited-action[PARTIAL] (#4833, BUILT in f58345950; stays open until the maintainer has used it) the per-sentence editor’s save path (InlineClaimEditor) goes through ClaimStore.patch — the SAME audited claim.patch action (harness/audited-action-layer.md), not a second bespoke save call — and onSave now receives the claim the SERVER returned (previously the stale pre-edit claim, so nothing showed until a later change-stream echo); the digest, the biography, and the row editor each splice that one returned claim in place, none reloads. Pinned: same four suites as kg.read.edit-unit-is-the-claim above.
  • kg.read.edit-affordance-is-legible[GAP, DESIGN] (#4833) seen live 2026-09-19: “[Edit]” as a text run after every sentence is wrong, and the whole paragraph underlined is hard to read. Expected, stated as intent not a decided mechanism: an ICON, not a text word, and reaching the SOURCE should be exactly as easy as reaching the edit affordance — today’s underline-the-whole-sentence treatment competes with normal reading. Open questions, not decided here: which icon (a pencil glyph? a chevron/disclosure?), whether it shows on hover/focus only or always, and whether “reach the source” gets its own icon beside “reach the editor” or the two share one affordance that offers both on activation.
  • kg.read.one-editor-not-two[BROKEN, DESIGN] (→ #1888) seen live 2026-09-19: two different claim editors exist — the Inspector’s popover (InlineClaimEditor) and the Claims table’s sheet (EditClaimSheet) — with different layouts for the same edit. Same question raised for entities (an Inspector popover vs. wherever the table edits one). In the popover, the subject is an unlabeled button — nothing tells a user that is where the subject is edited, a legibility defect independent of the two-editor problem. Expected: ONE editor, reached the same way from every surface a claim/entity appears. Open questions, not decided here: which shape wins (popover, sheet, inline-in-place), and whether entity editing literally reuses the same component or its own parallel one.
  • kg.read.editor-fields-vary-by-kind[GAP, DESIGN] (→ #1768) seen live 2026-09-19: the claim editor’s fields are the same regardless of the claim’s KIND (fact, claim, hypothesis, quotation…) — a hypothesis has a source a plain fact might not need, and so on. Also: the editor’s “Page” field for a claim’s source is imprecise — the true source is a page OR a SEGMENT (a bbox-anchored region within a page), and the editor doesn’t distinguish them. Expected, stated as intent: the editor’s field set is schema-driven per claim kind, and the source field names a segment when the claim has one, not just its containing page. Open questions, not decided here: the exact field set per kind (this spec does not enumerate KnowledgeClaim’s kind taxonomy here — that belongs to kg-tables.md’s claim CRUD section), and whether page-vs-segment is a single field that specializes or two fields shown conditionally.
  • kg.read.sentence-opens-source-highlighted[BROKEN] (#4834, retagged 2026-09-19 from the maintainer’s own test session) clicking a sentence in the entity’s biography does NOT make Reader and Preview follow, and Preview shows “No selection” throughout — verified live, not inferred. The lesson, stated plainly: ClaimSourceLandingTests (cited below) passed and this behavior still failed on screen, because those tests are source scans and pure functions — none of them mounted a pane. A green pinning test proved the mechanism’s SHAPE is right; it never proved a real pane reads it. Kept PARTIAL/BROKEN distinction alive going forward: passing unit tests are necessary, never sufficient, for a behavior whose claim is about what appears on screen. What follows below (BUILT in e71bb070b for…) describes the mechanism as designed and unit-tested; it is the CLAIM under test, now known false in the running app. Cause, VERIFIED on disk (2026-09-19, not a hypothesis): KGFocusState.focusClaim assigns focusedEntityId = entityId unconditionally, and entityId defaults to nil; the reveal call site in ContentView+StateEvents.swift (~line 539) omitted the argument, so every sentence reveal cleared the focused entity, and DocumentInspector.inspectorArm(hasDocument: false, focusedEntityId: nil) returns .empty — the biography is torn down mid-click, by the Inspector’s own correct rule acting on now-wrong input. Slice A (e71bb070b) is the commit that added this call, so this is a REGRESSION, not a pre-existing gap. Fixed in 3f017efac: the reveal now passes the focused entity; RevealPreservesEntityFocusTests drives the real KGFocusState and inspectorArm. The build PASSES, but the tests have NOT executed (a locked screen blocked the run) and the fix has NOT been seen working. Tag stays BROKEN until the maintainer sees it work. The two other omitting call sites, DocumentKGSurface.swift (~line 405) and ForceDirectedGraphView+Render.swift (~line 128), are now FIXED and entityId is a REQUIRED argument (c1e88973a) — built, untested, unseen, same status as the primary fix. A guard for reveals that legitimately change the entity is in progress. Preview’s “No selection” symptom is now EXPLAINED, verified at the tree — one cause, both symptoms: ContentView+RootLayout.swift has an unguarded .onChange(of: kgFocusState.focusedEntityId) that sets sourceRevealDocument = nil — the old reveal changed the focused entity to nil on every click, so that clear fired every time, tearing down Preview’s reveal the same way it tore down the Inspector’s. The earlier pane-plan hypothesis (Preview asking PaneContentPlan for an unhandled entity- selection kind) is REFUTED: that code path’s entitySelection branch is never reached by the app, and both Preview/Reader leaves already read the reveal-aware document. the claim excerpt, the biography sentence, the digest sentences and the statement row (double-click and Open Source). Extended in 82ae96b9b to claim cards (the quote button, the quick-look Reveal, and the card tap — all three share one path — plus the attestation and corroboration rows) and the graph tab (the document’s knowledge surface, the force-directed graph’s edge click, both web-pane coordinators) — all now reveal in both panes without changing the selection. A “related document” row deliberately stays a plain navigation (it picks a document, not a statement), and one display-only request on the quick-look chip was deliberately left alone since changing it would do nothing. Still NOT built for table ROWS, and this is a design decision recorded, not an oversight: a single click on an Entities/Claims table row IS a selection and drives the open — unchanged, because that’s what a table row click means. The Claims table instead gained a “Reveal Source” context-menu item that shows the evidence WITHOUT selecting; the Entities table gets no equivalent, because an entity has many claims and many documents, so “its source” is not one thing to reveal. Pinned: ClaimSourceLandingTests (file fichero/Tests/Unit/general/Views/Library/ClaimSourceLandingTests.swift, suite ClaimSourceLandingTests). editing and source-reveal are two SEPARATE gestures today (edit → EditClaimSheet; navigate → ClaimSourceNavigationStatehandleOpenClaimSource, ContentView+StateEvents.swift:397-446). The ruling wants ONE gesture: open the claim editor AND reveal the highlighted source together. focusKGSourcePreview (ContentView+SourceNavigation.swift:147) already reveals a source WITHOUT changing the current selection — that is the existing seam to build this on, not a new mechanism. The span/region/page-only precision rules ClaimSourceRequest already computes (ClaimSourceRequest.swift:53-68) are correct and reusable as-is. Sharpened (2026-09-18, maintainer testing session): a single click must highlight BOTH representations of the same span at once — the passage in the Reader’s WebKit TRANSCRIPT text and the corresponding region on the source IMAGE — not one or the other depending on which surface happens to be showing, and neither highlight changes the current selection. This is the concrete, dual-surface form the “one gesture” ruling takes once there are two source renditions (transcript text and page image) to keep in sync, not a new requirement beyond it.
  • kg.read.source-request-declares-intent[BROKEN] (#4834, retagged 2026-09-19 from the maintainer’s own test session) clicking a claim in the Inspector goes to the page, the highlight is not precise, and he loses his place — the exact “reveal without losing your place” property this behavior claims does not hold on screen. Cause, VERIFIED on disk (2026-09-19, not a hypothesis) — the same root cause as kg.read.sentence-opens-source- highlighted above: the reveal’s own call to focusClaim omits the entity argument, so focusClaim assigns its default — nil — unconditionally; the focused entity clears, and the Inspector’s own arm rule shows nothing focused. A regression of e71bb070b (Slice A added this call); fixed in 3f017efac, build passes, but NOT yet seen working (tests haven’t executed). Tag stays BROKEN until the maintainer sees it work. The lesson, stated plainly: ClaimSourceRequestTests/SourceNavigationContractTests (cited below) passed and this behavior still failed live, because those tests are pure-function/contract tests — none mounted a pane. Kept as evidence that a green pinning test proves the mechanism’s SHAPE, not that a real pane reads it correctly. Restated ruling: from an Inspector showing a claim paragraph, clicking around must never cost the user their place. What follows below (BUILT in e71bb070b: destination has no default and every construction site states one; open until the remaining knowledge surfaces move off .reader) describes the mechanism as designed and unit-tested — the claim under test, now known false in the running app. Pinned: fichero/Tests/Unit/general/Models/ClaimSourceRequestTests.swift (suite ClaimSourceRequestTests) and fichero/Tests/Unit/general/Views/Inspector/SourceNavigationContractTests.swift (suite SourceNavigationContractTests). ClaimSourceNavigationRequest .destination silently defaults to .reader at every one of its five construction sites feeding one shared request bus — the fix drops that default and makes destination a REQUIRED argument. KNOWLEDGE surfaces (the biography, claim cards, entity/claim rows, KG graph surfaces) request BOTH panes from one click: the source image highlights the region and the Reader transcript highlights the passage, from one resolved location, with no sidebar-mode write and no selection change. The Inspector keeps the entity it is showing: the revealed source is transient window state that Preview and Reader follow and the Inspector ignores, cleared by any real selection change. NAVIGATIONAL surfaces (the source outline, the annotation list/inspector, the artifacts inspector) keep .reader and today’s selection-changing behavior — they are legitimately asking to GO there, not merely preview it. The current silent .reader default is what makes today’s “click loses your place” bug possible at all — this is the concrete mechanism behind kg.read.lives-in-reader’s “reveal without losing your place” property. Design/planning only (code lane, 2026-09-18) — nothing built; app source is frozen while the maintainer tests.
  • kg.read.span-reuses-existing-location-resolver[BROKEN] (#4834, retagged 2026-09-19 from the maintainer’s own test session) confirmed live: the highlight the resolver produces is not precise, consistent with kg.read.source-request-declares-intent’s finding above — one resolve is reached, but what it lights up on screen is wrong. Cause, VERIFIED on disk (2026-09-19), the same root cause as the two behaviors above: focusClaim is called without the entity argument and clears the focused entity, a regression of e71bb070b (Slice A); fixed in 3f017efac, build passes, but NOT yet seen working (tests haven’t executed). Tag stays BROKEN until the maintainer sees it work. The lesson, stated plainly: ClaimSourceLandingTests (cited below) passed and this behavior still failed live — the test proves one resolver is called once, not that its result renders correctly in a mounted pane. What follows below (BUILT in e71bb070b: one locationService.resolve feeds both the Reader passage channel and the source-image region, with no new engine call) is the mechanism as designed and unit-tested, now known imprecise in the running app. Pinned: fichero/Tests/Unit/general/Views/Library/ClaimSourceLandingTests.swift (suite ClaimSourceLandingTests). the sentence-click-to- highlight path needs NO new engine call: revealResolvedSource already calls the existing location resolver (POST /api/locations/resolve); its ResolvedLocation already carries page/bbox/char-range, today only resolvedDocumentId is read from it. The fix is reading more of what’s already returned, not adding a second resolver — one resolver, one highlight channel (the existing NotificationCenter page/highlight consumer), never a parallel path to keep in sync. Design/planning only (code lane, 2026-09-18) — nothing built.

C. Ruling 2 — re-centred on the page’s entity, honestly

  • kg.read.recentred-on-page-entity[BROKEN] (#4837) today’s app ALREADY re-centres — that is the defect, not an absence: let subject = first ? entityName : "they" (EntityDigestView.swift:663) makes the page’s entity the subject of every claim where it appears in entity_ids (claims.py:1058, “any mention” — subject, object, or merely named nearby via the alias-substring scan, extractors.py:1665-1683), regardless of the claim’s own subjectCanonical, which is never read. The safety rule the spec must state: a claim is re-centred ONLY when its verb has an INVERSE entry in that language’s table; otherwise it keeps its TRUE subject — never a false sentence. No inverse-phrasing table exists anywhere yet (this is stage 4, kg.read.lexicalisation below) — until it does, re-centring MUST fall back to the true subject, which is a smaller, immediate fix distinct from building the table.
  • kg.read.render-uses-claim-subject[OK] (b6052b42a, interim — the app’s loop now states each claim’s own subject via the same resolver the claim rows use; pinned by EntityClickthroughTests.objectSideClaimRendersTrueSubject, EntityClickthroughTests.noSentenceContainsThePronoun, EntityClickthroughTests.emptySubjectClaimsAreSkipped; the loop itself retires with the Reader rendition). Before the fix: (RENDER layer specifically, the largest and cheapest of three wrong-subject causes — fix first) EntityDigestView.swift:663’s ternary never reads the claim’s own subject; ClaimLine.swift:54 on the SAME screen gets it right (drops the subject only when it equals the group subject). Fixing this one line — read subjectCanonical, keep “they”/re-centring only behind a verb’s inverse-table entry — resolves most of the “wrong subject” complaint without touching extraction or resolution at all.
  • kg.read.extraction-subject-accurate[PARTIAL] (#4836, kept OPEN — see below) (EXTRACTION layer) 7c6f1aae5 fixed most of what this behavior tracked, verified by running the cited tests for real (59 passed): (i) English dependency labels are now per-language and correct — en_core_web_sm’s real scheme (dobj/dative/nsubjpass/agent, never UD’s obj/obl/nsubj:pass) is its own _LangDeps table, so English objects are no longer empty and English passives are no longer lost; (ii)/(iii) a relative pronoun (“que”/”cual”/”cuyo” family) is now in the pronoun gate and a relative clause’s whole subtree is excluded from the subject span; (iv) a passive now yields its logical subject only when the agent is stated — with no agent, no triple, and subjectless clauses are counted/logged, never silently bound to an earlier sentence’s subject; (v) a pronoun subject resolves ONLY to a NAMED antecedent in the SAME sentence (matched by identical source_text) — cross-sentence binding, or no source_text to prove sameness, drops the item rather than guessing. A recipient also now keeps its preposition (“to Pedro Mosquera”) rather than reading as the bare object — see kg.read.object-slot-has-no-role below for the ongoing scope of that specific gap. Pinned: test_spacy_svo_validator.py::TestEnglishRecipientKeepsItsPreposition (both tests), test_pronoun_subject_resolution.py::TestPronounSubjectResolution (all 6), test_svo_write_quality.py::TestPronounSubjectsNeverLand (all 3).

Fixed and tested, verified at HEAD (82937b118): the Spanish impersonal “se” passive case this paragraph previously reported as BROKEN — “Se vendió la mina a Pedro Mosquera” proposing the mine as the seller, “Se le entregó la escritura” making the deed the one who delivered — no longer proposes the patient as the subject. The small Spanish model puts the patient at plain nsubj and marks the “se” itself: expl:pass for an impersonal/passive clause, expl:pv for a true reflexive. A clause with an expl:pass child now goes through the existing passive rule, so with no stated agent it yields no triple (counted as skipped, never a false claim); a true reflexive (“Juan se fue a Quito”) keeps its real subject. Known, honestly-disclosed conservative loss, not a false claim: “Se vendió la mina por Juan Asprilla” states its agent but still yields nothing, because the parse doesn’t attach the agent where the agent search looks — it loses a claim, it never states a false one. Pinned: test_spacy_svo_validator.py::test_se_passive_with_dative_clitic_yields_no_triple, ::test_se_passive_with_recipient_yields_no_triple, ::test_se_passive_plural_with_place_yields_no_triple, ::test_true_reflexive_keeps_its_real_subject, ::test_se_dice_impersonal_report_verb_yields_no_triple_but_keeps_the_embedded_clause (5 tests, all passing on the exact sentences this paragraph names). This was the ONE remaining wrong-subject shape keeping this behavior PARTIAL after 7c6f1aae5 — with it fixed and tested, #4836’s own scope is resolved; #4836 stays open only for the maintainer to verify and close, not because a known gap remains in this behavior’s own claim.

Concurrency fix is PARTIAL, not proven, and said so honestly: the cached spaCy Language object had no lock while two derivative workers could parse concurrently (_page_morphology/propose_triples share one instance per language); a lock now wraps both parse call sites. TestConcurrentCallsDoNotHang (both tests) drains worker threads with a hard join timeout so a regression FAILS FAST rather than hanging the whole pytest process the way the original bug did — but per the fixing commit’s own message, “the race did not reproduce deterministically, so the regression tests guard the shape, not the race.” A test that cannot force the race it’s guarding against is real coverage of the SYMPTOM (a hang) and not proof the underlying data-corruption race is fixed — kept honest here as PARTIAL evidence, not [OK]. - kg.read.object-slot-has-no-role[GAP] (#4832) a proposed triple’s object slot carries no ROLE — a recipient (“to Pedro Mosquera”) and a patient (“the mine”) differ today only by whichever preposition survived in the text, not by a stored, queryable distinction the entry composer (kg.read.biography) could render on. Stated as INTENT for the composer work, not a regression: the recipient-keeps-its-preposition fix above (7c6f1aae5) makes the text itself honest, but a renderer still cannot ask “which object is the recipient?” without re-parsing the preposition string — a role field (recipient/patient/instrument/etc., seeded from what each language’s dependency label already distinguishes at extraction time) is the composer’s own work to add. Not built. - kg.read.resolution-avoids-bad-merges[PARTIAL] (#4842, RESOLUTION layer, smallest of the three contributors) upsert_entity’s fuzzy fallback (SequenceMatcher ≥ 0.78 when vectors are absent, _entity_writer.py:550-553) can fold a father/son or namesake pair; the guards (_terminal_surname_diverges, a mid-band review queue) are careful and no bad merge was reproduced in this review — INFERRED as a contributor, not verified as a live bug. Do not retune the resolution thresholds globally to chase this; route more cases to the review queue instead (a global retune re-binds FUTURE imports differently from past ones).

D. Ruling 3 — the paragraph lives in the Reader

  • kg.read.lives-in-reader[GAP] (#4838) today the paragraph lives in the INSPECTOR (DocumentInspector.swift:207, DocumentInspectorEntitiesTab+Rows.swift:58), not the Reader pane. The click handler that should drive the Source pane is mode-era: handleOpenClaimSource (ContentView+StateEvents.swift:397-446) sets sidebarMode and selects the source document directly, and never reads ClaimSourceRequest.destination (.reader is set at ClaimSourceRequest.swift:89, but nothing in Views/Shell/ContentView reads destination — confirmed by grep, zero matches). Confirmed live 2026-09-19 (maintainer test, A3), where a prior pass had only flagged this as a precise check, not yet manually verified: selecting an entity (reproduced on Antonio Mondragon) shows NOTHING in the Reader or Preview — worse than the predicted “replaces the inspector,” it is an empty pane in both, not even the document the entity was found in. The expected behavior, stated without picking a mechanism: something useful should show — the entity’s claims list or its readable paragraph. See #4838 (this behavior’s own tracker) and #4855 (the Statements/Claims duplication below), both commented with this evidence. Cause, VERIFIED by reading (structural, not a bug in one call site): the Preview and Reader document-resolution paths each have three tiers, and all three tiers are DOCUMENT-shaped — none has a branch for a bare entity selection with no document behind it at all, so there is no path to anything to show. #4838 (already cited above) is the fix this structural gap needs. Cross-spec note: the exact matrix-row text for ui/modes-to-panes.md (code lane’s file, not edited here) is given verbatim in this spec’s Migration section below, for the maintainer to relay.
  • kg.read.statements-lens-becomes-the-paragraph[BROKEN] (#4855) no lens should describe itself using the OTHER lens’s own words — Statements and Claims render the SAME KnowledgeClaim rows two ways, and today each tooltip claims the other’s territory. Verified BROKEN at HEAD: DocumentKGSurface.swift:101’s .digest (the Reader’s “Statements” lens) reads “every subject-verb-object statement we know about this ENTITY,” although the lens is scoped to a DOCUMENT, not an entity; .claims’s tooltip (:105) reads “statements extracted from the document, grouped by source” — genuinely different wording, but both are KnowledgeClaim rows, one grouped by entity as WebKit prose, the other by source as a native list, and neither tooltip says what actually tells them apart. Direction (this spec’s own ratified plan): Statements should BECOME the readable paragraph — sourced, clickable sentences drawn from the entry composer (kg.read.biography, GET /api/kg/entities/{id} /readable) — while Claims stays the structured, editable table; then the two differ in KIND, prose versus data, instead of overlapping in the same “statement” language. No fix in flight for this file as of this pass.

E. Ruling 4 — source language only

  • kg.read.source-language-only[BROKEN, partially] (→ #4839) the ruling itself (no gloss, no cross-language realisation, ever) has nothing to remove — grep of paragraph.py, readable.py, document_view.html, EntityDigestView.swift, and ClaimLine.swift shows none of them read the _en gloss fields on a claim. But the DEFAULT the ruling implies — each sentence in ITS OWN source language — is not honored either: readable.py’s language is a PARAMETER for the WHOLE paragraph, defaulting to "es", that nothing derives from the claim (:185); an unknown language silently falls back to English glue (:191); and the app’s live renderer hard-codes English words directly ("they" at EntityDigestView.swift:663; " and "/Oxford comma at paragraph.py:91-92; "source:"/"excerpt:"/"Footnotes:"/"p." at paragraph.py:263-267, 329-333; fixed S-V-O word order in three places). A claim has no single language field today — only source_languages: list[str] (models/knowledge.py:1648) — so “language per claim” means reading source_languages[0]. Deferred, recorded not built (ruling 4 itself, not a gap): multilingual gloss / cross-language realisation — explicitly out of scope, not forgotten.
  • kg.read.aggregation-never-crosses-languages[BROKEN] (#4839) readable.py’s aggregation key is subject + verb ONLY (:126) — it would merge claims across languages into one sentence with one glue language, the opposite of “language per claim.” No test catches this because the pipeline has never been run on a genuinely mixed-language entity page.

F. The six-stage NLG pipeline — retagged against what a screen actually shows

readable.py shipped 2026-09-10 (stages 1, 2, 3, 5, 6; 24 tests) — but per kg.read.one-renderer above, it has zero callers. Every tag below reflects that: “[PARTIAL] — engine-only” means built and tested, reachable from no screen.

  • kg.read.content-determination[PARTIAL] — engine-only (#4647) select_entry_claims (readable.py:46) selects “any mention” (subject, object, or named) for an entity; splitting by ROLE (subject/object/mention, distinct sections) is new work tracked under the entry composer (kg.read.biography below), not this stage. Pinned: 4 tests in test_readable_representation.py (content-determination suite).
  • kg.read.order.chronological[PARTIAL] — engine-only (#4648) as before, plus: confirmed built as a pure function and unit-pinned (readable.py:89, d8afa61e6, 1e6661b45, 6 tests); “unwired” is the SAME gap kg.read.one-renderer names, carried forward to the entry composer.
  • kg.read.order.by-source[PARTIAL] — engine-only (#4648) same as chronological above.
  • kg.read.aggregation-keeps-objects[BROKEN] (#4649) render_aggregation (readable.py:193-196) prints a COUNT instead of the objects when count > 1 — “Juan Asprilla sold 2 veces” loses what was sold. Retagged from the earlier “[PARTIAL, unwired]” framing: this is not just unwired, it is WRONG even in isolation, verified by running it. Also loses citations: Aggregation.claim_ids (readable.py:112) keeps the ids, but render_aggregation returns a bare str, so the ids are gone by the time there is a sentence to click — this is why kg.read.every-sentence-sourced below is [BROKEN], not [PARTIAL].
  • kg.read.every-sentence-sourced[BROKEN] (#4840) every rendered sentence must carry >= 1 claim id through to the click target. Today it does not survive aggregation (see above); the app’s live renderer (A) DOES carry a source per sentence (fichero-claim://<id> link, EntityDigestView.swift:393-397) since it never aggregates — so the property holds only in the UNAGGREGATED, wrong-subject path, and fails in the aggregated, engine path. The entry composer (kg.read.biography below) must fix both at once.
  • kg.read.referring-expressions[PARTIAL] — engine-only, and orphaned (#4651) referring_expression (readable.py:144) is BUILT (a surname heuristic: “Asprilla”, “Cruz” for “María de la Cruz”) but stage 6 (render_aggregation/realisation) NEVER CALLS it (:185 on). The app’s live renderer doesn’t use referring expressions either — it uses literal “they”.
  • kg.read.lexicalisation[MISSING] (#4650) stage 4 — an (event-type, role) pair → a verb phrase in the claim’s language, via a per-language table including the INVERSE map (kg.read.recentred-on-page-entity depends on this) and notarial first→third-person formulas. Not started (readable.py:20-21 says so in its own docstring). The per-language inverse map is seeded from verbs that actually occur most frequently in the maintainer’s own libraries — a read-only frequency count through the running app, not an invented verb list.
  • kg.read.biography[PARTIAL] — the read route now exists (#4750, cc2da29ae) the entry composer has a real, tested read endpoint: GET /api/kg/entities/{id}/readable returns {entity_id, paragraph, sentences[]}, each sentence carrying its text, offsets into the paragraph, a nullable language, its claim ids, the page entity’s role, and a revoiced flag — an unknown entity 404s rather than returning an empty paragraph. render_entry now fetches by query (unioning BOTH ways a claim belongs to an entity — linked in entity_ids, or named as subject, since the two fields drift in real libraries) instead of scanning every claim in the table. Pinned: test_routes_kg_render_readable.py::TestGetEntityReadable (all 5: test_404_for_unknown_entity, test_returns_the_shape, test_offsets_slice_into_the_paragraph, test_null_language_round_trips, test_a_claim_belonging_to_another_entity_is_not_returned). Still not [OK]: this commit is the ROUTE and the query-not-scan fix, not the aggregation fix — whether render_aggregation now keeps objects (kg.read.aggregation-keeps-objects) and never crosses languages (kg.read.aggregation-never-crosses-languages) was not re-verified here and those two behaviors are unchanged by this citation. Nothing in the APP calls this route yet — the Reader rendition (kg.read.lives-in-reader) is still the unbuilt other half; until it lands, the app’s five renderers (kg.read.one-renderer) are unchanged by this route existing.
  • kg.read.genre.regest [MISSING] (#4653) — one dated paragraph per document, in order (calendar of docs).
  • kg.read.genre.gazetteer [MISSING] (#4653) — a place’s assertions gathered as an entry.
  • kg.read.genre.index-concordance [MISSING] (#4653) — name/term → its attestations, sorted.
  • kg.read.confidence-visible [MISSING] (#4751) — certainty surfaces as hedge words + corroboration (ruling, 2026-09-12): hedge words tuned to confidence, PLUS a triangulation signal — how many independent sources assert the factoid and whether any contradict — and always the linked sources. Today’s app shows a bare “×N” (EntityDigestView.swift:407-412) — the exact device the 2026-09-12 ruling says misleads (a bare number reads as an invented score); words (“three sources agree”) are the fix, not a redesign.
  • kg.read.clean-triples-before-render[GAP] (#3808) as before, the raw SVO/KVO triples a representation reads from are noisy — repeated near-duplicate labels for the same entity/predicate, ungrouped multi-participant events. Partly already mitigated: dedup and pronoun-quality gates exist at extraction time (svo_quality.py, spacy_svo.py:251) — this behavior’s remaining scope is label-once canonicalization and reified multi-participant events, not a from-scratch problem. representation reads from are noisy — repeated near-duplicate labels for the same entity/predicate, ungrouped multi-participant events — and today’s readable-representation layer renders straight from them. Cleaning belongs upstream of rendering: label-once (canonicalize a repeated label to one form), dedup (collapse near-identical triples before they reach a representation), and reified events (a multi-participant happening becomes one event node with roles, not N separate flat triples). Every kg.read.* behavior below reads cleaner once this lands; not built yet on either side.

G. Provenance, integrity, and honesty of a re-voiced sentence

  • kg.read.provenance-linked [OK, extend] — every statement keeps its citation marker → source anchor. This is TRUE of the app’s live path (fichero-claim://<id> link, EntityDigestView.swift:393-397; ClaimSourceRequest.request(for:), ClaimSourceRequest.swift:76-103, refuses to draw a highlight it cannot vouch for) — kept [OK]. It is NOT true of the engine path once aggregation is involved — see kg.read.every-sentence-sourced above, tagged broken for that reason — this behavior covers the UNAGGREGATED, per-sentence case only; do not read this OK tag as covering the aggregated engine path too. Pinned: test_paragraph_rendering_helpers.py::test_render_narrative_merges_and_offsets_align (marker↔claim↔offset for the OLD paragraph.py path, itself unused per kg.read.one-renderer — kept as a reference test, not evidence of a live behavior).
  • kg.read.vocabulary-closure[GAP] (#4841) the testable form of “the app never invents”: a re-voiced sentence (re-centred per ruling 2, or aggregated) is still HONEST when every rendered word traces to the claim’s own text, an entity’s canonical name, or an entry in the language table — never free generation. Three UI/data disciplines make this checkable rather than just asserted: (i) a click always shows the VERBATIM source passage, highlighted; (ii) the claim panel shows the stored subject/verb/object exactly as extracted, beside the rendered sentence, so a re-voicing can be inspected against its source; (iii) a vocabulary-closure test asserts every word in a rendered sentence comes from one of the three sources above. None of the three exist yet.
  • kg.read.cite-to-segment[PARTIAL] (#4652, → #974) a citation resolves not just to a document/page but to the page SEGMENT (the bbox/region the claim was extracted from). Retagged from [MISSING]: this is PARTLY BUILT in the app already — ClaimSourceRequest.swift:61-64 already carries region data through to a highlight — the spec previously said MISSING and was wrong. What remains: the ENGINE side (paragraph.py emits one marker per claim after a merged sentence, :318-325, locating the superscript, not a sentence span — there is no sentence span in ParagraphRenderResponse, so “click the sentence” itself, as opposed to “click the marker,” is unrepresentable there) and the entry composer’s sentences[].start/end contract (kg.read.biography) closing that gap. → #974 frames the same chain more broadly as a first-class KG edge type: in-text marker → bibliography entry → claim, surfaced per-document AND library-wide — the library-wide aggregation is additional scope.
  • kg.read.expose-kg-on-hover [MISSING] (#4652) — hover/click on a statement reveals what the KG knows behind it — location, dates, roles, confidence, the raw SVO — rendered readably (not raw JSON), as the bridge from prose back to structure back to source.
  • kg.read.audit-history [MISSING] (#4660) — expose the factoid’s HISTORY, not just its current state: the original extracted names before canonicalisation, how entities were merged (merged_into_id), the curation_state (blessed / rejected / merged) and who/when (created_by, created_at, attribution_chain). Much of this is already stored — the render surfaces it readably so a reader can see how a factoid came to read the way it does, not just trust it.
  • kg.read.generation-provenance [MISSING] (#4652) — the render is no-LLM, but the underlying CLAIM was extracted by a model+prompt+run; that generation provenance (which model, which prompt version, which run) is exposed alongside the source, so a reader sees not just where the factoid came from but how it was made. Ties to run-scope provenance logging.
  • kg.read.no-llm[BROKEN] (#4652) NO generative model may be anywhere in the render path. paragraph.py and readable.py call no model — VERIFIED by reading both files whole, confirming the intent — but retagged [BROKEN] rather than [MISSING]: an LLM biography endpoint IS live on the engine, POST /api/kg/entities/{id}/bio (api/routes/kg/render.py:117-191) calls chat(...) (:156) and WRITES the result into entity.description (:172-188), which EntityDigestContent displays directly ABOVE the deterministic biography (EntityDigestView.swift:324-329). The Swift wrapper (generateKGEntityBiography) has no in-app caller, but CLI/MCP/agents can reach the route — this is a live violation of the AI-as-instrument north star, not a theoretical one. Retiring it is the creative director’s decision, explicitly not decided here — see Open Questions. A future guard test must assert on CALLS (patch llm.chat to raise) and on PURITY (same input → same output), not on imports: paragraph/readable transitively import fichero_server.llm via the models package, so an import-based guard fails as written.
  • kg.read.language-of-svo[BROKEN, overstated] (#4651) retagged from [PARTIAL]: the spec previously read as if a claim’s language is genuinely read and honored per-sentence. It is not — the existing tests (test_realises_single_claim_in_spanish, test_realises_aggregated_count_and_places_in_english, test_unknown_language_falls_back_to_english_glue, fichero-server/tests/unit/knowledge/test_readable_representation.py) prove a language PARAMETER can be passed and produces different glue words — they do not prove a claim’s own stored language is ever READ and threaded through. See kg.read.source-language-only and kg.read.aggregation-never-crosses-languages above for the concrete gaps this behavior was overstating.
  • kg.read.background [MISSING] (#4752) — the representation is computed in the background and stored (auto-throttled, like embeddings — the machine stays usable), not synthesized per web request. Last priority — string joining over at most 500 claims per entity; do this only if measured slow, not pre-emptively.

Build: the Reiter & Dale NLG pipeline (six small, testable Python stages)

The classic NLG pipeline decomposes into six stages, and each is a small pure function with its own unit test — which is exactly why this whole feature is headless-testable: 1. Content determination — which factoids belong in this entry (by entity, date range, genre). 2. Document structuring — order/section them: chronological, by life-stage, or by theme (family / property / litigation). (= kg.read.order.*) 3. Aggregation — collapse repetition: “seven witness appearances” → one sentence with a count and a place distribution. (extends _group_claims) 4. Lexicalisation — each (event-type, role) pair → a verb phrase, in the claim’s language via a per-language lexicon table (add a language by adding a table, not code). 5. Referring expressions — full name on first mention, surname after, pronoun within a paragraph (pronoun/agreement rules are per-language). 6. Realisation — agreement + morphology, per language. Library landscape (researched 2026-09-10, for a Python backend): - Default (ponytail): Jinja2 + per-language lexicon/rule tables — deterministic, zero new deps, exactly how paragraph.py already works. The active language(s) come from the project/collection setting (ruling 3), not a hardcoded list — each language is a data table loaded on demand; the current corpus seeds Spanish, but nothing in code assumes it. Lsjbot proves this scales (and warns of flatness — mitigated by aggregation + referring-expression variation, stages 3 & 5). - Any-language escalation: Grammatical Framework via the pgf Python runtime — one abstract tree → concrete grammars per language; this is what Abstract Wikipedia uses. The principled answer when a language’s morphology outgrows rule tables. Heavy (write grammars) — adopt only when it earns its keep. - pyrealb — native-Python (no bridge), realizes EN+FR deterministically; a light step if EN/FR realisation is needed before committing to GF. - SimpleNLG-ES (Java, via server/bridge) — mature Spanish realiser if the ES rule tables prove too weak before GF is worth it. NEVER translate a claim across languages — render each in its own (the _en fields are a pre-existing extraction-time translation, usable for an English rendering, not a license to translate other languages).

paragraph.py already implements a thin slice of stages 3–6 for a single paragraph; this spec extends it stage by stage to entry/biography scale, each stage landed test-first.

Prior art to build on (RDF/linked-data crowd — researched 2026-09-10): the semantic-web community verbalizes graphs to text with LD2NL / SPARQL2NL / SemWeb2NL (rule+template RDF→text), whose pipeline (lexicalization → single-triple realization → clustering → ordering → grouping) mirrors Reiter-Dale — independent confirmation the deterministic path is sound. CIDOC-CRM is the ISO ontology the cultural-heritage crowd uses for exactly this factoid substrate (events, actors, places, times); our factoids map to it, so they can be imported from / exported to an RDF server as linked open data (cf. Enslaved.org). The readable render must work over a factoid whether it came from the extraction pipeline OR an RDF import — same substrate, same rendering. CIDOC-CRM import/export itself is LATER — it belongs to the import/export engine, not this spec. This spec keeps the DH deterministic approach; CIDOC-CRM I/O is a separate future milestone. Corrected 2026-09-18: the previous wording here (“stages 1-3 shipped, 12 tests green”) was true and misleading at once — shipped to a module with zero callers is not the same claim as shipped to a reader. See kg.read.one-renderer for the honest framing and the migration plan below for how it gets wired.

Test matrix (BACKEND-heavy — this is why it’s the right headless target)

Leg This surface? Pins File
Pure rule (py) y ordering (chronological/by-source), grouping, hedging by confidence fichero-server/tests/unit/knowledge/test_readable_representation.py
Backend (pytest) y the render endpoint returns ordered prose + markers for a seeded entity fichero-server/tests/…
No-LLM guard (py) y render module calls no LLM client; output is a pure function of its claims same
Multilingual (py) y a claim renders in ITS language; adding a language = adding a lexicon table same
Availability (Swift) y the reader surface wires the readable render fichero/Tests/Unit/**
Snapshot (Swift) y a biography renders legibly (chronological + by-source states) fichero/Tests/Unit/**
Click-around (XCUITest) y open an entity → read its biography → click a citation → its source fichero/Tests/UI/**

Hard-gate: kg.read.no-llm (integrity — the AI-as-instrument north star; no fabrication) and kg.read.provenance-linked (every statement traceable to its source).

The user’s DH survey maps future visualization surfaces; each is its own spec when taken up. Captured here so the research isn’t lost, deliberately out of scope for the readable-text spec: - Finding-aid graphs (ego networks, confidence-scored edges) — Six Degrees of Francis Bacon, Linked Jazz, CBDB. Grape (Swift d3-force port) or sigma.js in the new SwiftUI WebView. - Time axis — storyline/arc/attestation timelines; Digital Panopticon life-courses; Swift Charts rule/bar marks; deck.gl TripsLayer for movement. - Map + graph — Chocó terrain (Copernicus/SRTM DEM), HydroRIVERS, Codazzi/Comisión Corográfica sheets georeferenced via Allmaps (IIIF), HGIS de las Indias jurisdictions; MapLibre GL (web, shareable) embedded via WebView, native Map(.mapStyle(.hybrid(elevation:))) for the light in-app view. - Matrices — reorderable co-occurrence heatmaps (Swift Charts RectangleMark); hierarchical edge bundling onto fondo→legajo→expediente. - Embedding space — Chart3D (macOS 26) or Nomic-Atlas-style zoomable map of the sqlite-vec vectors. - RealityKit space-time cube — Chocó map on the floor, time up, trajectories threading mines and towns (Hägerstrand); exports USDZ/glTF for sharing. Earns 3D only when the third axis means something. - Architecture principle (from the survey): Python owns layout (networkx/igraph/Graphviz/UMAP → x,y,z,t in SQLite); the app draws coordinates (SwiftUI Canvas); the SAME coordinates feed a web front end (FastAPI) so the Mac view and the shareable link never drift.

Cross-surface & authoring (the invariant to hold — audit tracked separately)

The factoid substrate is rich (audited 2026-09-10: who-asserts, date, place, role, citation, Toulmin, confidence, language, generation-provenance all present). The open concern is whether every dimension we can STORE is also: (a) visible in the UX (KG tables / inspector), (b) authorable by BOTH a person (manual) and the extraction pipeline (LLM) — never LLM-only, and (c) tested end-to-end (backend ↔ MCP ↔ CLI ↔ UX — the Constitution’s hard-gate invariant). This readable rendering is a READ view of that substrate; the authoring/visibility audit belongs to kg-tables.md / kg-entity-inspector.md. Tracked, not assumed.

Idea (Abstract Wikipedia / GF): multilingual NLG organized into abstraction levels lets code be shared across languages and splits labour between programmers (grammars) and authors (content); a Controlled Natural Language puts a human in the loop to author/correct factoids in constrained prose that round-trips to structure. A candidate future authoring path — deterministic, no-LLM, and the same abstract representation the render reads.

Rulings (creative director, 2026-09-12)

  1. Default ordering = chronological, changeable. A reader lands on the chronological reading; a control switches to by-source. Either way every statement always shows its source and date — a click on any statement opens its provenance (source anchor → kg.read.cite-to-segment). Ordering is a lens over the same evidence, never a filter that hides it. (Resolves kg.read.order.chronological as the default; by-source is the toggle.)

  2. Confidence reads as hedge words AND triangulation — the point is “this is not magic.” Default to hedge words in the prose (“is said to have”, “probably”), but the surface must make clear the render is evidence, not an oracle. A bare confidence dot/number can mislead (it reads as a made-up score), so express certainty primarily through corroboration / triangulation — how many independent sources assert the same factoid, and whether any contradict — not just the stored per-claim confidence. So kg.read.confidence-visible surfaces: (a) hedge words tuned to confidence, (b) the corroboration count (“three sources agree”, “only one source, uncorroborated”, “sources disagree”), and (c) always the linked sources themselves. A separate “less certain / disputed” section is allowed but is not the primary device. Never render a single low-confidence, single-source claim as flat fact.

  3. Realisation language is GENERIC and chosen at onboarding — no hardcoded language list. This is a Python-backend concern and must not bake in Spanish/English. The active language(s) are a user setting picked at onboarding, per project — and settable per collection. The per-language lexicon/rule tables are data (a table per language), loaded for whatever language(s) the project/collection declares; adding a language is adding a data table, never code (see stage 4/6 below). Connective/structuring prose follows the entity’s dominant claim language, but the set of languages the pipeline realises is driven by that project/collection setting, not by a fixed pair. (Claims themselves always render in their own language regardless — kg.read.language-of-svo.) The current corpus is Spanish-first, but that is a setting value, not a code assumption — the design ships generic and seeds Spanish.

Migration — the ordered deliveries (Fabel review, fabel-kg-readable, 2026-09-18)

Principle: one renderer, in the engine; the app draws what it is given. Each delivery ships alone, with its own tests. Wrong subject first, because it’s the cheapest fix with the largest visible payoff.

  1. Stop asserting the wrong subject (app, small). biographySentences reads the claim’s own subjectCanonical; the literal string “they” is removed. Tests: object-side and mention-only claims keep their TRUE subject. Interim fix — this whole loop is deleted in step 6. → kg.read.render-uses-claim-subject.
  2. Extraction subject fixes (engine; new extractions only, never a batch re-run — see Risks). Fix the English dependency-label mismatch (spacy_svo.py:50-51); add the relative pronoun to the pronoun gate; exclude the relative-clause subtree from the subject span; bind pronoun antecedents only within the same sentence, else route to review. Tests: the exact sentences traced in this spec’s kg.read.extraction-subject-accurate. → that behavior.
  3. Entry composer (engine, #4750, the keystone). render_entry(entity_id, ordering)sentences: [{text, start, end, language, claim_ids: [...], role: subject|object|mention, revoiced: bool}] + citations, served at GET /api/entities/{id}/readable. Fixes aggregation (keep objects, never merge across languages, language per claim) and wires stage 5 (referring expressions) in. Tests: every sentence has ≥ 1 claim id; offsets slice back to their own tokens; the function is pure; a mixed-language entity page gets each sentence in its own language. → kg.read.biography, kg.read.aggregation-keeps-objects, kg.read.every-sentence-sourced, kg.read.aggregation-never-crosses-languages.
  4. Stage 4 as data (engine, #4650). One file per language: conjunction, “times”, “at”, page abbreviation, date pattern, role order, the verb → inverse-phrase map, and notarial first-person → third-person formulas. Seed es and en from the verbs that actually occur most frequently in the maintainer’s own libraries (a read-only count through the running app — never an invented verb list). Test: adding a third language means dropping in a new file, no code change. → kg.read.lexicalisation.
  5. Re-centring (engine). role: object + an inverse-table entry → the page entity becomes the subject, revoiced: true; no entry → the claim’s TRUE subject, unchanged. Property test: the page entity is never the subject of a verb lacking an inverse entry, plus the vocabulary-closure test from Open Question 2. → kg.read.recentred-on-page-entity, kg.read.vocabulary-closure.
  6. Reader rendition (app). The Reader pane gains the entity paragraph, drawing sentences[] from the entry composer’s contract above; biographySentences is deleted. Sentence click → Source pane through the focusKGSourcePreview seam, selection untouched; an aggregated, multi-source sentence opens a short list of its sources instead of guessing one. The Inspector keeps the statements list, merge, aliases, and history. → kg.read.lives-in-reader.

Exact matrix-row text for ui/modes-to-panes.md (that spec is the code lane’s — relay this, don’t edit it here): “Entity or claim row selected → Reader = the readable paragraph (drawn from the entry composer’s sentences[]); Source = the cited page, span highlighted, revealed via focusKGSourcePreview without changing the current selection; Inspector = KG curation (statements list, merge, aliases, history) unchanged.”

Sub-slices, as scoped by the code lane (2026-09-18) — design/planning only, nothing built, app source is frozen while the maintainer tests: - 7a — the source reveal goes through the pane-aware seam. Same shape as kg.read.sentence-opens-source-highlighted below. - 7a refinement — the request must DECLARE its intent, not default to one. ClaimSourceNavigationRequest.destination today silently defaults to .reader at every one of its five construction sites feeding one shared request bus — the refinement drops that default and makes destination a REQUIRED argument every request factory must state. KNOWLEDGE surfaces (the biography, claim cards, entity/claim rows, KG graph surfaces) must request .preview: Source resolves and highlights the passage, the Reader’s own content is UNCHANGED, no sidebar-mode write, no selection change — exactly the “reveal without losing your place” property kg.read.lives-in-reader already names as missing. NAVIGATIONAL surfaces (the source outline, the annotation list/inspector, the artifacts inspector) keep .reader and today’s selection-changing behavior — they are legitimately asking to GO there, not merely preview it. New behavior: kg.read.source-request-declares-intent[GAP] (→ #4834) a source-navigation request must state whether it wants a REVEAL (knowledge surfaces: resolve once, highlight both the source region and the Reader passage, no selection/mode change, Inspector unchanged) or a NAVIGATE (surfaces whose whole job is going to the source); the current silent .reader default is what makes today’s click “lose your place” bug possible in the first place. Not built. - Span → page-region needs no new engine call. revealResolvedSource already calls the existing location resolver (POST /api/locations/resolve); its ResolvedLocation already carries page/bbox/char-range, today only resolvedDocumentId is read from it. New behavior: kg.read.span-reuses-existing-location-resolver[GAP] (→ #4834) the sentence-click-to-highlight path reuses the SAME resolver and the SAME highlight channel (the existing NotificationCenter page/highlight consumer) everything else already uses — one resolver, one highlight channel, no second path to keep in sync. Not built. - 7c — the claim editor’s date field (wire fields already exist on the model; only the editor UI is missing). Folds into kg.read.edit-unit-is-the-claim below. - 7b — a per-sentence edit affordance, saving through the audited action. InlineClaimEditor saves through ClaimStore.patch — the typed PATCH /api/claims/{id} endpoint IS the audited claim.patch action (harness/audited-action-layer.md) — and returns the freshly-patched claim so the caller never has to re-fetch or guess at the server’s normalized result. New behavior: kg.read.editor-saves-through-the-audited-action[GAP] (→ #4833) the per-sentence editor’s save path is ClaimStore.patch → the audited claim.patch action, returning the updated claim — not a bespoke save call. Not built. - 7d — the subject-as-entity-picker is BLOCKED on the engine: _apply_claim_patch must first learn to update entity_ids and regenerate claim.text when subject_entity_id changes (already named as a gap in kg.read.edit-unit-is-the-claim below) before the app side has anything real to call. 7. Edit from the sentence (app + engine). Acting on a sentence opens the claim editor AND reveals the highlighted source in one gesture (7a/7b above). Add the date field to the editor (7c); the subject field becomes an entity picker that patches subject_entity_id + entity_ids (never just svo_* text), and the patch regenerates claim.text (7d, blocked on the engine). When a claim renders on more than one entity’s page (e.g. a sale — direct on the seller’s, inverse on the buyer’s), the editor says so before saving (“also appears on Pedro Mosquera’s page”) — mechanically this already works, since claimStore.changeToken triggers a re-read on every page showing that claim (EntityDigestView.swift:286). → kg.read.edit-unit-is-the-claim, kg.read.sentence-opens-source-highlighted. 8. Guard and honesty (engine). A no-LLM test that patches llm.chat to raise and asserts purity (not an import check — paragraph/readable transitively import fichero_server.llm via the models package, so an import-based guard fails as written); retire /bio (pending the creative director’s decision, Open Question 8); corroboration in words, replacing “×N” (#4751). Background precompute (#4752) is LAST, and only if measured slow — this is string joining over at most 500 claims. → kg.read.no-llm, kg.read.confidence-visible, kg.read.background.

Risks to real libraries (Fabel review, section 9)

  • Nothing above requires a migration or rewrites a stored row. Deliveries 1 and 3–6 are read-side only.
  • Delivery 2 (extraction fixes) changes FUTURE extractions only. Do not batch re-extract to “fix” old claims: a re-run is protected for claims a human has EDITED (record_superseded matches them back), but an un-edited row whose subject span changes on re-extraction will not dedup against its old self — it will DUPLICATE. If a cleanup pass is ever wanted, it must be a reviewable proposal list through the running app’s action layer, never a standalone-engine pass (standing rule: never standalone-engine surgery).
  • Delivery 7 (the subject picker) changes entity_ids on a curated claim — it must go through claim.patch (undoable, audited) and must ADD the new entity without silently removing the claim from pages a person already placed it on.
  • Tightening the alias-scan (the render-time amplifier named in kg.read.render-uses-claim-subject) would change which pages OLD claims appear on if re-run. Leave stored entity_ids alone; fix this at RENDER time by role instead (delivery 3’s role-aware content determination), not by re-running the scan.
  • /bio retirement: existing entity.description values may be model-written (metadata.biography_provenance marks them). Do not delete any of them — label them, and let the person keep or clear each individually.
  • Entity-resolution thresholds (the RESOLUTION layer, kg.read.resolution-avoids-bad-merges): do not retune globally to chase the wrong-subject complaint — any global change re-binds FUTURE imports differently from past ones. Route more ambiguous cases to the review queue instead.

Open questions

Copied from the Fabel review’s design questions (section 7) with its recommendations. The manager is proceeding on each recommendation unless the creative director says otherwise, with ONE exception (#8) that stays an open question FOR him specifically.

  1. A verb with no natural inverse (“doy fe”, “compareció”, intransitives, anything not yet in the table). Recommendation (proceeding): the sentence keeps the claim’s true subject and sits after the re-centred run — never the entity’s name in front of a verb it did not perform.
  2. Is a re-voiced sentence still sourced? Recommendation (proceeding): yes — the source span is the evidence, the sentence is a rendering. Keep it honest three ways: (i) a click always shows the verbatim passage, highlighted; (ii) the claim panel shows the stored S/V/O beside the rendered sentence; (iii) a vocabulary-closure test asserts every rendered word comes from the claim, an entity name, or the language table — never free generation. → kg.read.vocabulary-closure.
  3. One aggregated sentence, several sources. Recommendation (proceeding): the sentence is one click target that opens a short list of its sources (document, page, verbatim passage), each driving the Source pane; a single-source sentence goes straight there. Keep the objects in the sentence (“sold the mine and two slaves”) — a count alone is not a reading.
  4. One claim on two entity pages (a sale: direct on the seller’s page, inverse on the buyer’s). Recommendation (proceeding): both are renderings of ONE claim id — one edit re-renders both, and the editor says so before saving.
  5. First-person notarial voice under a named subject (“Adolfo Hurtado doy fe”). Recommendation (proceeding): quote it (Adolfo Hurtado: “doy fe…”); third-person rewrite only for the fixed formulas already listed in FORMULAIC_PATTERNS.
  6. Spanish sentences with no explicit subject, dropped at extraction today. Recommendation (proceeding): surface as review proposals with an empty subject; never auto-bind to a guessed one.
  7. Merely-mentioned claims (entity neither subject nor object, often placed there by the substring alias scan). Recommendation (proceeding): out of the paragraph, into an “also mentioned in” list below it.
  8. The LLM biography endpoint (/bio). Recommendation: retire it; label existing model-written descriptions, never delete them. This one stays open FOR THE CREATIVE DIRECTOR specifically — not proceeding on the recommendation without his ruling, since it’s a north-star/integrity call, not an implementation detail.
  9. The “×N” marker vs. the 2026-09-12 ruling that a bare number misleads. Recommendation (proceeding): words (“three sources agree”).
  10. A language with no table yet. Recommendation (proceeding): render the claim verbatim, no glue words — never silent English.