# Texts, interlinear glossing, and grammar

Connected material rather than isolated words: stories and passages with ordered
sentences, interlinear glossed text (IGT), and the dictionary's structured grammar
description. Read `api-basics` first if you haven't.

## Texts vs. example sentences

Two different objects, easy to confuse:

- An **example sentence** belongs to a sense — it illustrates a word. Written
  nested inside the entry (`senses[].example_sentences`) or standalone via
  `POST …/sentences` and linked by id.
- A **text** is a connected passage — a story, a narrative, a procedural
  description — with its own ORDERED list of sentences (each able to start a new
  paragraph). Independent of entries. Use the `…/texts` routes.

A sentence can be both: a text's sentence linked to a sense also serves as that
sense's example.

```
POST …/texts                    create the text with its ordered sentences
GET  …/texts                    list (filter with ?tag=<name> and/or ?collection=<id>)
GET  …/texts/{textId}           the text + sentences + audio + speakers in ONE call
POST …/texts/{textId}/tags      genre / motif / tale-type classification
```

## Collections (organizing a large corpus)

A dictionary with hundreds or thousands of texts (a story anthology, a hymnal,
multi-chapter works, session transcripts) needs structure, not one flat list.
**Collections** are self-nesting groups — series › book › chapter, up to 3
levels — and every text can belong to one:

```
GET    …/texts/collections                  the whole forest (nest via parent_id)
POST   …/texts/collections                  create ({ title, parent_id?, sort_key? })
PATCH  …/texts/collections/{collectionId}   rename / re-parent / reorder
DELETE …/texts/collections/{collectionId}   children + member texts DETACH (never deleted)
```

Attach a text by setting `collection_id` on text create or PATCH; order within
the collection with `sort_key` (a fractional index — an opaque string that
sorts lexicographically; omit it to append, or read the siblings and pick any
string between two neighbors to insert between them). A parent assignment that
would make a collection its own ancestor (or nest past 3 levels) is rejected
with `422`. Tags and collections are complementary: collections hold the
corpus's SHAPE (what contains what, in order), tags classify across it
(genre/motif in any collection).

When importing a structured corpus, mirror its own organization: one collection
per source volume/section, texts ordered as the source orders them.

## Interlinear glossed text (IGT)

Supply gold `tokens` on any sentence write shape (`SentenceInput`,
`TextSentenceInput`, `SentencePatch`). It's a map of **orthography code → ordered
token list**, usually just `default` (the vernacular line):

```json
{
  "text": "Mbwa wangu anakula",
  "translation": { "en": "My dog is eating" },
  "tokens": {
    "default": [
      { "form": "Mbwa",    "gloss": { "en": "dog" } },
      { "form": "wangu",   "gloss": { "en": "my", "default": "1SG.POSS" } },
      { "form": "anakula", "gloss": { "en": "eat", "default": "3SG.PRS" } }
    ]
  }
}
```

That token list is the **shared index** everything else aligns to 1:1 — the gloss
line, word→entry links, and karaoke audio timings. Get it right and the rest follows.

Rules worth internalizing:

- **`form` must be byte-identical to its appearance in `text`.** `start`/`end`
  offsets are optional on write; the server derives them by walking your ordered
  forms against the text with a left-to-right cursor. Strip a tone mark or footnote
  marker from the form but not the text (or vice-versa) and derivation fails.
- **The `gloss` convention is locked**: language-neutral grammatical category codes
  (`3PL`, `PFV`, `CLF`) go under the reserved `default` key; per-language lexical
  glosses (`tiger`, `虎`) under their language codes. A reader on gloss-language X
  sees `gloss[X] ?? gloss.default`, so a neutral code under `default` survives every
  gloss-language switch — putting it under `en` would make it vanish for a Chinese
  reader.
- **A token needs no entry.** `entry_id` is optional and independent of `gloss` —
  grammatical morphemes and portmanteaux often have no headword but must still be
  glossable.
- **Multi-word → one gloss** = one token spanning the whole char range.
- **Ambiguous match? Send `candidates`, not nothing.** When a form matches several
  entries (homographs — endemic wherever the spelling neutralizes a contrast like
  tone, length, or stress, and worst among the highest-frequency words), write the
  shortlist: `{ "form": "…", "candidates": ["<entry_id>", "<entry_id>"] }`. Those
  tokens land in the `ambiguous` facet of the suggestions queue for a human to
  resolve in one pass. Dropping the link instead loses exactly the words readers
  tap most. Leave `status` absent alongside candidates.
- **Never stamp `status: "confirmed"` on a link no human confirmed.** An ABSENT
  status is the meaningful "unmatched" state: it's what keeps a token reachable by
  the auto-matcher and visible in the review queue. Stamp it and the token is
  invisible to both — permanently unlinked and unreviewable. Use `auto` for a
  machine match you want a human to check.
- `status: "ignored"` marks punctuation, keeping the offset and timing arrays
  aligned.
- Legend codes are matched as a substring anywhere in a gloss cell, so portmanteaux
  like `eat PFV` still render small-caps and tap-to-expand. There's no per-token
  "is this grammatical?" flag to set.
- **Omit `tokens` entirely** and the server tokenizes the sentence and word-matches it
  against the dictionary's entries at write time. Supply them only when your source
  has a real glossed alignment worth preserving. Order does not matter: entries
  created or renamed later fill in the still-unmatched words automatically.
  With `status` omitted, a token with a resolved `entry_id`/`sense_id` (or a linked
  morpheme) defaults to `confirmed`; a bare, gloss-only, or candidates-only token
  stays statusless, i.e. unmatched. A gloss is metadata, not a link — the matcher
  links glossed words against the entries and keeps the gloss verbatim.

A rows-only glossed source (aligned `[form, gloss]` rows with no separate vernacular
line) imports fine: omit `text` for that orthography and send its tokens; the server
joins the forms with spaces to build the text.

Also available per sentence: `citations` (a source ref WITH a page/example
`locator`), `example_label` (the author's own example number, e.g. `"(2a)"`), and
`discourse_role` (information/salience role, so a narrative can render foreground vs.
background). `sources.orthography` declares which script a source's forms use.

## Structured grammar

The grammar surface is live and entry-linked:

| | |
|---|---|
| `…/grammar/sections` | Hierarchical sections: parallel-language markdown prose, entry/sense links, usage conditions. A section may be headless (body-only). |
| `…/grammar/sections/{sectionId}/sentences` | Attach example sentences **by reference** — `POST …/sentences` to create, then attach the id. |
| `…/grammar/clause-slots` | Clause-template slots. |
| `…/grammar/glossing-abbreviations` | The legend that makes gloss codes expandable for readers. |
| `…/entries/{entryId}/grammar` | The reverse lookup: which grammar sections cite this entry. |

There is no separate grammar-intro endpoint — the introductory prose is simply the
first top-level section.

Fill in the **glossing abbreviations legend** whenever you import glossed material —
but ONLY for what the standard Leipzig catalog can't cover. The site already expands
every standard code (`1SG`, `PFV`, `PL` …) in every dictionary, localized into the
reader's UI language; a curated row that just restates "first person singular" adds
nothing and blocks that localization. Register a code when:

- it's outside the standard set (`1SG>2SG`, `URG.FEM`, `Ø`), or
- this language's usage genuinely differs from the standard meaning (a "dual" that
  marks two *or more*).

Wording rules for curated expansions:

- **The dictionary stands alone.** Never write "the book's X" or reference the source
  PDF — readers who never saw the source will just wonder "what book?". Source
  attribution belongs in the import report, not in gloss tooltips.
- Anchor a code to its morpheme when that identification is the point
  (`negative — the suffix -aží 'not'`), but don't echo the morpheme the reader just
  tapped on.

## The word→entry matching queue

Sentences are tokenized and matched against entries on ingest, and every entry
write re-matches the words it names — you never need to import in a particular
order or trigger matching yourself. Forms that matched nothing, or matched
ambiguously, land in the review queue at `…/suggestions`, where they can be linked,
used to create an entry, or ignored. Ignoring a form dictionary-wide writes an
`ignored_forms` row the matcher then skips. Per-token actions go through
`…/sentences/{sentenceId}/tokens/actions`. `POST …/analyze` (`{ text_id }`,
`{ sentence_ids }`, or `{ unanalyzed: true }`) re-runs the matcher on demand — the
API twin of the reader's "Refresh matches" — for the rare catch-up (a sentence
written before 2026-09-18, or an entry edited outside the API).

**Bound morphemes match too.** A lexeme written with a leading hyphen (`-k`) is a
suffix, a trailing hyphen (`ka-`) a prefix. When a word matches no whole lexeme but
has exactly one reading as `stem+suffix` / `stem+suffix+suffix` / `prefix+stem` over
free lexemes, the matcher suggests that segmentation: the token gets `status:'auto'`
and `morphemes[]`, each morpheme carrying `entry_id` (unique) or `candidates` (the
homograph suffixes `-k¹ -k² -k³`). Two readings → the word stays unmatched for a
human. Per-token actions `segment` (`{ morphemes: [{ form, separator?, gloss?,
entry_id? }] }` — forms must spell the word) and `unsegment` store or drop a
segmentation; a `segment` is gold (`confirmed`) and replaces the word-level link.

## Cognate sets (word families)

For comparative material — multi-variety dictionaries, cognate ledgers, etymological
indexes — the dictionary has an N-ary layer above pairwise relationships:
a **cognate set** groups every entry that descends from one ancestor form, and the
reconstruction itself lives on the set (it is not a word in any documented variety,
so it is not an entry). Readers see these as "Word families".

```
GET/POST …/cognate-sets              list; create (single, or batch ≤1000 with per-item results)
GET/PATCH/DELETE …/cognate-sets/{setId}
POST …/cognate-sets/{setId}/members  idempotent upsert by entry_id
DELETE …/cognate-sets/{setId}/members/{entryId}
```

Shape rules worth internalizing:

- **A set needs a head**: a `reconstruction` (send it WITHOUT the asterisk — display
  adds it) or, when no ancestor has been reconstructed yet, a `gloss` ("water"). A
  family can be established on member evidence first and gain its proto-form later.
- **`proto_level`** ("Proto-Polynesian", "Proto-Oceanic") is free text — keep ONE
  consistent spelling per level within a dictionary; the UI suggests previously
  used values.
- **Members are entries** (`entry_id`, optional `sense_id` narrowing) with a
  per-member `note` and an `uncertain` flag. Prefer flagging a debatable reflex
  `uncertain: true` with a note over omitting it — absence is invisible, a flagged
  member is reviewable.
- **Sources are registry refs, never prose**: `citations: [{ slug, locator? }]`
  (`locator` = the set/page number in the comparative source). Register the
  comparative work via `POST …/sources` first.
- **When to use pairwise `cognate` relationships instead**: exactly two entries and
  no reconstruction-level claim. Don't mirror a set's members as pairwise links —
  the set already renders the family on every member's entry page.
- Entry reads embed `entry.cognate_sets` summaries when the entry belongs to any.

## Etymology records (typed origins per entry)

Where a cognate set groups MANY entries under one ancestor, an **etymology
record** states ONE entry's origin in queryable, typed form:

```
GET/POST …/entries/{entryId}/etymologies
GET/PATCH/DELETE …/entries/{entryId}/etymologies/{etymologyId}
```

Shape rules worth internalizing:

- **`verdict` is the only required field**: `inherited` (carries
  `reconstruction_level` + `reconstruction`, sent WITHOUT the asterisk, and/or a
  `cognate_set_id` pointing at the word family), `loan` (carries
  `donor_language`, `donor_form`, and a free-text `stratum` layer label), or
  `unknown` — an `unknown` verdict with a note ("origin unclear, see
  discussion") is a legitimate, useful record.
- **Multiple records per entry are COMPETING HYPOTHESES** — file each proposal as
  its own record with its own sources rather than merging them into one.
- A `loan` may still carry `reconstruction_level`/`reconstruction` when the word
  was borrowed FROM a reconstructed stage.
- `confidence` is `certain` | `probable` | `possible` (or omitted).
- **The free-prose `linguistic_history` entry field stays as-is** — it's the
  discussion; the typed record is the queryable verdict. When a source states
  both, fill both.
- Sources are registry refs (`citations: [{ slug, locator? }]`), same as
  everywhere else.
- Entry reads embed `entry.etymologies` when any records exist.

## If a shape is awkward

This surface is newer than the rest of the API and still being shaped by real corpus
imports. If your data doesn't fit — `POST …/feedback` with `{ message }`. That
feedback genuinely changes what gets built.
