# oq public JS module API — v0 (experimental)

> **This package.** The module is [`src/public-api.js`](./src/public-api.js);
> this file is the contract. Snapshot lock: [`test/public-api.test.js`](./test/public-api.test.js)
> (`EXPECTED_SURFACE`). Types: [`src/public-api.d.ts`](./src/public-api.d.ts).
> Living sandhi spec: [`docs/sandhi-taxonomy-status.md`](./docs/sandhi-taxonomy-status.md).
> Package data flow: [`docs/DATA_FLOW.md`](./docs/DATA_FLOW.md).
> This package is the morphology source of truth.
> Engine bugfixes land here first; oq consumes a release after oq#888.

**There is no HTTP/REST API and nothing here to `curl`.** `oq` is a static
browser PWA with no backend server of its own — the closest thing to an "API"
is this JS module, `src/public-api.js`, which you `import` directly into
your own JS/TS code (browser or Node) and call as functions. The only things
you can actually point `curl` at are the third-party upstream data sources
`oq` fetches JSON from — see [`src/upstream-sources.js`](./src/upstream-sources.js)
for the URLs and [`docs/SOURCES.md`](./SOURCES.md) for each one's provenance,
licensing, and attribution requirements — those aren't an API `oq` provides,
just data it consumes. See
[Poking at upstream data directly](#poking-at-upstream-data-directly) below
for `curl`/`jq`/`sqlite3` examples against those sources.

Status of the surface defined by [`src/public-api.js`](./src/public-api.js)
(oq#394). Current version: **0.1.6**.

Version 0.0.1 cut PWA leakage from the package (oq-api#14):
`buildWord` / `analyzeWord` / conjugation labels take optional `EngineOptions`
(`linguistTerms`, `pronounPreference`, `storage`, `locale`) instead of reading
`localStorage` or oq `kc_exp_*` keys. Defaults need no browser and preserve
the previous stored-default surface forms (linguist terms OFF, pronoun
preference `"he"`). `t()` looks up morphology catalogs only (word-class terms,
conjugation labels, gloss/reason fragments); GUI catalogs stay in oq.

The same 0.0.1 surface also includes the Phase 3 engine extracts (oq-api#15):
custom-morpheme validation, identity-preserving sequence helpers, deconstruct
search extras, dictionary homograph merge, semantic-class/domain catalogs, and
katersat stem/gloss hints. Package version stayed **0.0.1**; oq view wiring waits
for oq#888.

Version 0.0.2 adds generic support for grammarian
`application_logic.special_ending_realizations`, preserving the affix and
following ending as separate analysis-chain morphemes while composing their
published complete surface realization.

Version 0.0.3 fixes the confidence and ranking of those mapped analyses: a
verified complete realization is exact rather than approximate, so it ranks
ahead of unrelated approximate fallback analyses.

Version 0.0.4 assigns mapped affix+ending realizations to a single explicit
surface span in both structured surface results and padded breakdown rows,
while preserving the following ending as a separate zero-width analysis item
and keeping spans ordered and non-overlapping.

Version 0.0.5 corrects per-morpheme ownership within those mapped spans by
using the following ending's citation-form suffix as the generic ownership
anchor.

Version 0.0.6 exports conjugation math (`conjugate`, stem derivation, ending
catalog, `matchConjugationEnding`) that previously lived only behind oq
shims, and drops the dead KalaalliCut / C-port pin from package docs.

Version 0.0.7 adds structured word-splitting markers (`splitWord`) and
`syllabify`. These are syllable / line-break splits, not morpheme spans.

Version 0.0.8 exports the headline picker Deconstruct uses (`headlineGloss`),
`buildMeaningTrace`, Builder next-step grammar (`canFollow` /
`pickMatchingSense` / `INITIAL_STATE`), and dictionary `getAllEntries` /
`suggestWordCompletions`.

Version 0.0.9 exports `injectDictionaryRoots`: turn dictionary headwords
into synthetic `dict_…` stem presets. Off by default — `analyzeWord` is
unchanged unless the caller concatenates the result.

Version 0.0.10 exports `presentAnalysis`: Deconstruct's answer policy
(sequence conjugation, citation reverse-lookup that drops competing cards,
headline, hide bare `dict_*` matches). Opt-in; `analyzeWord` golds unchanged.

Version 0.0.11 adds absolute `url` (frozen API snapshot) and `tarball_url`
(frozen install tarball) to `/api/latest.json`. The JavaScript export list
is unchanged.

Version 0.0.12 exports the remaining helpers a second frontend needs to
consume published data without reaching into `src/*.js`: `flattenGrammarianData`
(the published grammarian URL is `by_id`, while `buildEndingCatalog` wants a
flat array), `loadFullSource` (prime `getAllEntries` / `injectDictionaryRoots`
without a dummy search), plus display labels `formatWordClass`,
`wordClassInfo`, `labelForCategory`, `markedForm`, and `groupCategoriesByType`.

Version 0.0.13 exports the rest of the Builder/Dictionary algorithms a
greenfield client would otherwise copy: search syntax (`compileSearchPattern`,
`matchesSearch`), stem/word-class helpers (`stemWordClass`, `wordClassPath`,
`normalizeWordClassLang`, `WORD_CLASS_SHEET`), Builder category grouping
(`categoryToGroupType`, `capForRender`, `joinRuleForItem`), allomorphy
predicates the Builder paints (`nasalizesPrecedingStop`,
`nasalizesPrecedingConsonant`, `copiesPrecedingVowel`,
`assimilatesAlveolarSchwa`, `anchorSurfaceVariants`), conjugation subject-split
hint (`moodSubjectSplitKey`), and dict priming (`ensureFullLoaded`,
`randomEntry`). `debounce`, data-mirror URLs, IPA/Sumut indexes, related-words,
and color `init()`/`getTree()` stay out — those are PWA chrome or stateful.

Version 0.0.14 publishes human-readable and machine-readable What's New files
for downstream consumers. The JavaScript export list is unchanged. The
discovery payloads advertise the files through a `whats_new` object.

Version 0.1.0 exports the remaining pure output-normalization policies from
oq's Deconstruct view: interactive conjugation selections can be converted
back into structured analysis matches, and analysis sequences can be reduced
to portable Builder-handoff entries.

Version 0.1.6 fixes context-specific sandhi confidence for cited denominal
verb forms and preserves reverse-search roots when a longer literal root is
morphologically incompatible with the target's ending.

## Pages distribution

GitHub Pages hosts the module as a native ES import. Frozen patches stay at
`/api/vX.Y.Z/` and `/downloads/oq-api-X.Y.Z.tgz`. Each `X.Y` line also gets a
rolling copy:

```
https://jandahl.github.io/oq-api/api/v0.0-latest/public-api.js
https://jandahl.github.io/oq-api/downloads/oq-api-0.0-latest.tgz
```

`v0.0-latest` tracks the newest 0.0.x. When a breaking `0.1.x` ships, that
alias stays on 0.0 and `v0.1-latest` starts. Pin `/api/v0.0.N/` or
`oq-api-0.0.N.tgz` when you need a reproducible patch. Do not pin
`0.0-latest.tgz` — it is overwritten every deploy.

Pages replaces the entire site on each publish. Frozen snapshots and tarballs
are rebuilt from **git tags** (`vX.Y.Z`). After a successful deploy, Pages
tags HEAD as `v<API_VERSION>` if that tag is missing, so the next publish
still has a pin to rebuild. Do not pin `0.0-latest.tgz`.

Discovery (additive fields on the existing `latest.json` payload; new
`versions.json` index):

| URL | Payload |
|---|---|
| [`/api/latest.json`](https://jandahl.github.io/oq-api/api/latest.json) | Current HEAD: `version`, `tag`, `url` (frozen snapshot), `tarball_url` (frozen tarball), `api_url`, `release_url`, `whats_new`, plus `line` / `line_url` / `module` for the rolling alias. |
| [`/api/versions.json`](https://jandahl.github.io/oq-api/api/versions.json) | Every frozen patch and every `vX.Y-latest` line alias, each with `whats_new` links. |
| [`/whatsnew/0.0-information.md`](https://jandahl.github.io/oq-api/whatsnew/0.0-information.md) | Human-readable changes relevant to consumers on the cumulative 0.0.x line. |
| [`/whatsnew/0.0-information.json`](https://jandahl.github.io/oq-api/whatsnew/0.0-information.json) | The same entries as structured data: `{ schema_version, api_line, entries }`. |

The files are cumulative per `X.Y` compatibility line. Each entry identifies
the exact `X.Y.Z` release that introduced the change, and newest releases sort
first. Future lines use the same `/whatsnew/X.Y-information.{md,json}` pattern.

The live example at [`/examples/live-import.html`](https://jandahl.github.io/oq-api/examples/live-import.html)
imports `v0.0-latest` and lists these URLs at the bottom.

## Stability posture

**There is no stability promise yet — deliberately.** The morphology engine is
under heavy active development: the sandhi taxonomy is only partially
implemented, `OUT_OF_SCOPE_IDS` churns weekly, and the upstream grammarian
schema still moves. While `API_VERSION` is `0.x`, any commit may rename,
reshape, or drop any export. What v0 *does* give you:

- **One blessed entry point.** Import from `src/public-api.js` only.
  Everything else under `src/*.js` — including the ~100 other exports in
  `morph_engine.js`, `allomorphy.js`, `phonology.js`, `morpheme-meta.js`,
  `dict-data.js` — is internal plumbing, free to change without notice.
- **Deliberate drift.** `test/public-api.test.js` snapshots the export list;
  changing the surface requires updating the module, this document, and the
  snapshot together, plus an `API_VERSION` bump.
- **Detectability.** `API_VERSION` tells a consumer which surface it got.

A real 1.0 promise is gated on the stabilization criteria discussed in
oq#394 (sandhi taxonomy substantially complete, engine-behavior churn
flattened). This module is **not published to npm**; consume it by importing
the file directly. It is framework-free and runs both as a browser ES module
and under Node (≥ the version CI uses; no bundler, no build step).

## Quick start

```js
import {
  buildWord, morphemeEntryToPreset, findExactDictMatch,
} from "./public-api.js";

// entries: flattenGrammarianData(await fetch(GRAMMAR_MORPHEMES_URL).then(r => r.json()))
const seq = [stemEntry, affixEntry, endingEntry]
  .map((e) => morphemeEntryToPreset(e).seq[0]);

const { ok, word, approximate, closed } = buildWord(seq);
// → { ok: true, word: "qimmeqarpoq", approximate: false, closed: true, ... }

const dictHit = await findExactDictMatch(word); // entry object or null
```

## Surface

### Versioning

| Export | Contract |
|---|---|
| `API_VERSION` | Semver-shaped string, `0.x` while experimental. MINOR bump on any surface change, PATCH on doc/behavior-only fixes. |

### Word building — composer

| Export | Contract |
|---|---|
| `buildWord(seq, options?)` | Full pipeline in one call; returns `{ ok, word, approximate, closed, error, errorKey, errorAt, reason, details }`. `errorKey` and `details` are stable machine-readable diagnostics; do not parse `reason`. `options` is the same optional `EngineOptions` bag as conjugation labels; build itself does not read settings or storage. |

### Word building — engine-level pieces (advanced)

For consumers that need incremental building or their own pipeline wiring.

| Export | Contract |
|---|---|
| `WordBuilder` | Incremental engine: `init()`, `add(morpheme)` → error code, `removeLast()`, `getWord()` (raw, un-respelled), `getError()`, `hasSandhiConflict()`. |
| `ROOT`, `AFFIX`, `INFLECTION`, `ENCLITIC`, `DERIVATIONAL_ENCLITIC` | Sequence-item `type` values for `WordBuilder.add()`. |
| `JOIN_NONE`, `JOIN_TRUNCATIVE`, `JOIN_ASSIMILATIVE`, `JOIN_ADDITIVE` | Coarse join operations. Rule values coming out of `morphemeEntryToPreset` may also be engine-internal sentinels — treat any rule value you didn't construct yourself as opaque. |
| `MORPH_OK`, `MORPH_ERR_*` (7 codes) | Error codes from `WordBuilder.add()`/`getError()` and `buildWord().error`. |
| `MAX_WORD_LENGTH`, `MAX_MORPHEMES`, `MAX_MORPHEME_LENGTH` | Engine limits. |
| `applyAllomorphy(seq)` | Tier-2 pre-pass: returns a new array with each item's `.text` rewritten to its correct surface allomorph given its neighbours. Pure. |
| `hasApproximateMorpheme(seq)` | Whether any item needs a process the engine doesn't implement (should be flagged approximate). |
| `nasalizesPrecedingStop(morpheme)` | True when this morpheme nasalizes a preceding stop (`ENCL_aa` family). Builder paints this as a join hint. |
| `nasalizesPrecedingConsonant(morpheme)` | True for `ENCL_guuq`. |
| `copiesPrecedingVowel(morpheme)` | True for `N_uneq_N`. |
| `assimilatesAlveolarSchwa(morpheme)` | True when this morpheme assimilates a host-final alveolar schwa (`N_cuaq_N`). |
| `anchorSurfaceVariants(morpheme)` | Citation/allomorph spellings a Builder search should accept as this morpheme, or `null`. |
| `validateSequence(seq)` | Word-class (N/V) grammar check: `{ valid, state, errorAt, reason }`. |
| `INITIAL_STATE` | Empty morphotactic state (`category: null`, `closed: false`). Start of a Builder sequence. |
| `canFollow(state, morpheme)` | Whether `morpheme` may attach after `state`. `{ ok, reason?, reasonKey? }`. Unannotated free-form items pass. |
| `pickMatchingSense(state, morpheme)` | Homograph sense whose `category_shift.from` matches `state.category`, or `null`. |
| `respellSurface(word)` | Whole-word display respelling (q→r, vowel openness, ŋ→ng). Apply once, after building — never inside the join pipeline. |
| `TYPE_INT` | `{ ROOT: 0, AFFIX: 1, INFLECTION: 2 }` — numeric `type` values for `buildCustomMorphemeItem`. Same numbers as `ROOT` / `AFFIX` / `INFLECTION`. |
| `buildCustomMorphemeItem(values, options?)` | Pure custom-morpheme validation. `{ ok: true, item }` or `{ ok: false, reason }`. No DOM. `options` is the uniform `EngineOptions` bag (unused by the builder itself). |
| `moveSeqItem(seq, from, to)` | Identity-preserving reorder: returns a new array with the item at `from` moved to `to`. Non-arrays become `[]`; out-of-range / non-integer indices return a shallow copy. |
| `isBuilderItem(item)` | True when `item` is already `toBuilderItem()`-shaped (the identity `buildWord` preserves). |
| `compileSearchPattern(query, advanced?)` | Limited search syntax → `RegExp` or `null`. `advanced: true` treats `query` as a regex. Ordinary punctuation is literal. |
| `matchesSearch(text, pattern)` | Case-insensitive test; resets `pattern.lastIndex`. False when `pattern` is null. |
| `joinRuleForItem(item)` | `{ rule, fromLeftSandhi }` — the join `WordBuilder.add` will actually run. Prefer this over reading `item.join` when `left_sandhi` is mapped. |
| `categoryToGroupType(groups)` | Map category key → group type from `groupCategoriesByType()` output. |
| `capForRender(items, limit?)` | `{ shown, overflow }` slice for Builder lists. Default limit 300. |

### Word-class colors

| Export | Contract |
|---|---|
| `WORD_CLASS_THEMES` | Serializable canonical `default` and `light` theme choices. |
| `getWordClassColors(classPath, theme?)` | Purely returns `{ border, fill, text }` for a hierarchy path using the supplied theme, without global state or initialization. |
| `formatWordClass(desc, opts?)` | Human label for a raw dictionary word-class string (`"taggit"`, `"v"`, `"oqaluut susalik"`). `opts: { lang?: "en"\|"da"\|"kal"\|"both", abbrev?: boolean }`. Unknown labels pass through. |
| `wordClassInfo(desc)` | Registry row for that label (`{ id, en, da, kal, abbrEn, classPath, … }`), or `null`. |
| `stemWordClass(desc)` | `"N"` / `"V"` / `null` for whether this label can seed a Builder root. Blank → `"N"`. |
| `wordClassPath(desc)` | Hierarchy path array for that label, or `[]`. |
| `normalizeWordClassLang(value)` | `"kal"` / `"en"` / `"da"` / `"both"` (default for anything else). |
| `WORD_CLASS_SHEET` | Static cheat-sheet rows (`{ id, en, kal, da, classPath, … }[]`). |

### Deconstruct — word analysis (the inverse of `buildWord`)

| Export | Contract |
|---|---|
| `analyzeWord(word, presets, opts?)` | Synchronous search returning verified matches. Each match includes `confidence: "exact" \| "approximate"` and `rankReasons: string[]`. |
| `analyzeWordAsync(word, presets, opts?, { signal }?)` | Same search, yielding to the event loop between chunks so it never blocks rendering; `signal` (an `AbortController`'s `.signal`) cancels a stale search, rejecting with a `DOMException` named `"AbortError"`. |
| `cacheAnalysisResult(word, result)` | Seeds the exact-match cache with a precomputed result (e.g. from completion generation), so a later `analyzeWord`/`analyzeWordAsync` call for the same word returns it directly regardless of `opts`. |
| `computeMorphemeBreakdownRows(items, word, seq)` | Pure, DOM-free layout computation behind oq's own per-morpheme breakdown table: given `glossSummaryItems(seq)`'s `items`, the built `word`, and the (unfiltered, including any `Ø` items) `seq` that built it, returns one row per non-`Ø`, glossed item with `{ item, label, marker, j, text, changedRanges, leftPad, rightPad, surfaceLeftPad, surfaceEnd, surfaceText, surfaceRightPad }`. `text` is the morpheme's own plain declared/citation spelling (never allomorph- or sandhi-resolved — e.g. `"vunga"`, not `"punga"` or `"rpunga"`); `changedRanges` (`{start, end}[]`, indices into `text`) marks which of its own letters don't survive unchanged into the real word — both a LEADING change (its own allomorph pick differing from citation, e.g. `"-vunga"` picking `"-punga"`) and a TRAILING one (the NEXT morpheme's boundary altering its tail, e.g. `"-qaq"`'s own final `"q"` becoming `"r"`) are covered uniformly, including identity swaps (a letter replaced by a different one), not just outright deletions. `leftPad`/`rightPad` are dot-padding counts around `marker+text` that sum to exactly `word.length`, right-anchored against the next row's real start (or the word's end, for the last row) whenever `text` is shorter than the real width its own boundary added; `surfaceLeftPad`/`surfaceEnd`/`surfaceText` describe the row's real, fully-resolved span within `word` directly. Column positions are derived from replaying the real build pipeline (not a naive prefix diff), correctly handling cases a naive diff gets wrong (e.g. a join that inserts a surface letter belonging to neither morpheme's own declared text). |
| `resolveMorphemeSurfaces(seq, word)` | Plain structured sound-change data with no UI/padding concerns and no `items`/gloss dependency — just the `seq` that built `word` and the `word` itself. Returns one record per real, non-`Ø` entry (unlike `computeMorphemeBreakdownRows`, this includes a glossless entry too — a display choice that function makes, not a structural fact this one inherits) with `{ j, id, marker, citationText, resolvedText, changedRanges, surfaceStart, surfaceEnd, surfaceText }`. `surfaceStart`/`surfaceEnd`/`surfaceText` are the morpheme's real, final resolved spelling and its exact span within `word` — the same ground truth oq's own "sound change" column shows, as plain data: every record's `surfaceText`, concatenated in order, reconstructs `word` exactly, with no gap or overlap. `changedRanges` is the same generalized sound-change marking `computeMorphemeBreakdownRows` exposes (see above), located within `citationText`. |
| `splitWord(word)` | Oqaasileriffik syllable / line-break splits as **data**, not an invisible display trick. Returns `{ word, syllables, breaks, hyphenated, visible }`. `word` is the input unchanged. `syllables` are the parts; `breaks` are offsets in `word` after which a break is legal; `hyphenated` inserts U+00AD for CSS wrap; `visible` joins with `-` for teaching / copy / logs. Orthogonal to `resolveMorphemeSurfaces`. Never mutates `buildWord().word`. Independent reimplementation of the described algorithm; no license is asserted over Oqaasileriffik's page. |
| `syllabify(word)` | Thin alias for `splitWord(word).hyphenated` — the form oq's Builder currently paints. Prefer `splitWord` in new consumers. |
| `presentAnalysis(query, analyzeResult, opts?)` | Opt-in Deconstruct policy. `{ matches, conjugation, headline, completions }`. Sequence conjugation (exact card, last item in `opts.catalog`) wins; else citation reverse-lookup (`lookupCitation("${prefix}vaa")`, then a capped scan of `opts.citations`, never a hidden full-dict loop) and **drops** competing `matches`. `hideBareDictRoots` default on. `analyzeWord` itself is unchanged. Pass `catalog` (from `buildEndingCatalog`) and optional `stemHints`. |
| `builderSequenceEntries(seq)` | Pure normalization for handing an analysis sequence to a Builder. Catalog-backed non-empty items become ids; anonymous items retain their text, numeric type/join, and morphology fields. Zero-surface (`""` or `"Ø"`) endings are omitted because they are useful analysis evidence but produce unusable blank Builder rows. Non-arrays return `[]`; inputs are not mutated. |
| `synchronizeConjugationMatch(presentation, match, result)` | Purely applies an interactive conjugation result to an analysis match. Returns a new match with `word`, `approximate`, and a catalog-backed structured ending after `presentation.prefixSeq`, preserving useful metadata from `presentation.baseMatch` (or `match`). An incomplete result returns `match` unchanged. This is the same output policy oq's Deconstruct conjugation foldout uses. |
| `DEFAULT_NODE_BUDGET` | Default `opts.maxNodes` for `analyzeWord` / `analyzeWordAsync` (100000). oq's settings chrome may override it; this package does not read that setting. |
| `isBareDictRootMatch(match)` | True when a deconstruct match is a dictionary-injected root plus empty continuers. |
| `suggestFuzzyRoots(word, presets, opts?)` | Edit-distance root suggestions when analysis finds no exact parse. `opts`: `{ maxDistance?, maxSuggestions? }` plus `EngineOptions`. |
| `suggestMorphCompletions(prefix, presets, maxCount?, options?)` | Prefix completions from the morpheme catalog (closed built roots). |


### Morpheme data — grammarian entries → presets

| Export | Contract |
|---|---|
| `morphemeEntryToPreset(entry, opts?)` | Maps one raw grammarian/katersat-schema morpheme entry to a preset; `preset.seq[0]` is the sequence item to feed `buildWord`. Pure. |
| `mergeMorphemeSources(results, sources)` | Merges multiple fetched morpheme sources into one deduplicated preset list: `{ presets, anyOk, failed }`. |
| `toBuilderItem(item)` | Preset `seq[]` item → numeric-typed engine item. Idempotent on already-numeric items. |
| `glossSummary(seq, opts?)` | Human-readable gloss chain for a sequence, as an array of pre-joined `"${marker}${text} — ${gloss}"` strings (one per non-filtered item — see `glossSummaryItems` below for what "filtered" means and why nothing is filtered here). `opts: {lang, showOther}` (oq#409, both optional, defaulting to `en`/`never` — the pre-#409 behavior) select the display language for each item's `plainGloss` and whether the non-preferred language is surfaced when it differs. Joins on each item's scholarly `gloss` field — for the shorter, single-sense phrasing oq's own UI actually renders (`shortGloss`/`rawShortGloss`), call `glossSummaryItems` directly instead (oq#824). |
| `glossSummaryItems(seq, opts?)` | The structured form `glossSummary` reduces to strings: one item per input morpheme, `{ marker, text, gloss, shortGloss, rawShortGloss, meaning, preset, stemIn, stemOut, moodLabel, valencyIn, valencyOut, valencyEffect, htrRole }`. `gloss` is the raw, scholarly, potentially multi-sense text; `shortGloss` is the single-sense, blank-filled phrasing oq's own Deconstruct/Word Builder UI renders; `rawShortGloss` is the same but with the composed stem left as a literal `"___"` instead of filled in — pick whichever fits your UI. `marker` is `""` for a root, `"+"`/`"-"` for an additive/truncative-joined continuer, or the literal string `"Ø"` for a null/zero ending (`morpheme-meta.js`'s own `marker = isRoot ? "" : !text ? "Ø" : ...`) — a `"Ø"` item's `text` is always empty, since a zero ending carries no real bound-morpheme spelling of its own. **Not filtered out for you**: oq's own rendered breakdown (`docs/morpheme-breakdown.js`'s `renderMorphemeBreakdown`) drops `marker === "Ø"` items before displaying a sequence, and most consumers building a similar per-morpheme breakdown display will want to do the same — but this function returns every item honestly, since a caller auditing the full sequence (not just displaying it) may need the Ø entries too. Same `opts` as `glossSummary`. |
| `resolveGlossText(plainGloss, meaning, lang, showOther)` | Resolves one morpheme's raw `plain_gloss` (`{en, en_short, da, da_short}`, any key optional) plus its scholarly `meaning` fallback into `{ text, tag, secondary }` for the given language preference and show-other mode (oq#409). |
| `headlineGloss(items, opts?)` | Composed headline for a `glossSummaryItems()` list — not always the last item. Walks backward past unfilled `"___"` and past Ø identity endings whose gloss only repeats `stemIn`. Prefers a structured `en_short`/`da_short` object. Strips a mood label already shown as a pill. `opts: { lang?, locale?, moodLabel? }`. Identity skip is locale-specific (`en`: leading `"one "`, `da`: leading `"et "`/`"en "`); unknown locales skip that filter. Returns `{ text, item, moodLabel }`. |
| `stripRedundantMoodLabel(text, moodLabel)` | Strip a leading `"label: "` / `"label "` when the mood pill already carries that label. |
| `buildMeaningTrace(text, items, last?)` | Colour-span ranges `{ start, end, seqIndex }[]` locating each item's `meaningContribution` in the composed sentence. Paint stays with the caller. |
| `flattenGrammarianData(data)` | Recover a flat entry array from either published grammarian shape: `by_id` (current `GRAMMAR_MORPHEMES_URL`) or legacy `flat`. Empty / unknown payloads return `[]`. Feed the result to `morphemeEntryToPreset` or `buildEndingCatalog`. `mergeMorphemeSources` already does this internally. |
| `labelForCategory(cat, options?)` | Locale-aware label for a raw category key (`"noun_cases_possessive"`). Falls back to de-underscored text when the morphology catalog has no entry. `options.locale` selects the catalog. |
| `markedForm(preset)` | Citation with Greenlandic boundary marker: stem unmarked, additive continuer `+form`, truncative/assimilative `-form`, null ending `Ø`. |
| `groupCategoriesByType(morphemeData)` | Bucket distinct `category` values under morpheme-type groups (`stem`, `derivational_affix`, `inflectional_ending`, …). Empty groups omitted. |
| `SCHEMA_MAJOR_VERSION` | The legacy flat grammarian data-schema MAJOR version. |
| `BY_ID_SCHEMA_MAJOR_VERSION` | The independent grammarian by-id export schema MAJOR version this surface consumes. |
| `GRAMMAR_MORPHEMES_URL` | Version-pinned URL of the published grammarian `morphemes.json`. |
| `checkSchemaVersion(meta, expectedMajor?)` | Returns a warning string on a real MAJOR mismatch in fetched data's `meta`, else `null`. |

### Engine options

Optional per-call bag on `buildWord`, `analyzeWord`, and the conjugation
label helpers. Defaults need no browser and do not read `localStorage`.

| Field | Default | Meaning |
|---|---|---|
| `linguistTerms` | `false` | Technical grammar terms instead of plain glosses. |
| `pronounPreference` | `"he"` | For 3sg/4sg labels: `"he"` / `"she"` / `"it"` / `"all"`. Gloss helpers that already took this argument keep their own default (`"all"`). |
| `storage` | omitted | Optional `StorageLike` (`getItem` / `setItem` / `removeItem`) for callers that want to persist engine markers. The library never opens `localStorage` itself. |
| `locale` | active locale (`"en"`) | Morphology-catalog language for this call. |

`EngineOptions` is a TypeScript type in `src/public-api.d.ts`, not a runtime export.

### Conjugation labels — resolved, ready-to-display text

The same friendly labels oq's own "conjugate to…" modal shows for a verb-mood
paradigm coordinate — resolved text, not a raw i18n key or a bare grammar
term. Plain-language by default (`linguistTerms` OFF), falling back to the
technical grammar term for a mood with no plain gloss. `resolvePersonLabel`
additionally honors `pronounPreference` for the two gendered person/number
combinations (3sg/4sg), unless you opt out.

| Export | Contract |
|---|---|
| `resolveMoodLabel(mood, options?)` | `mood` is one of the structured mood keys grammarian's `inflection.mood` publishes, uppercased with any `_DIFF`/`_SAME`/`_TR_GI`/`_DUAL` variant suffix `buildEndingCatalog`'s entries carry (e.g. `"IND"`, `"CAU_DIFF"`) — see `src/conjugation.js`'s `parseEndingEntry`. Returns `{ text, title }`: `text` is the label to show; `title` is the technical term to show as a tooltip when `text` is a plain gloss, or `null` when `text` already IS the technical term (nothing to disclose). |
| `resolvePersonLabel(person, number, opts?)` | `person` a number 1-4, `number` `"SG"`/`"PL"` (case-insensitive). Returns a plain string ("I", "you", "he", …). `opts` may include `EngineOptions` plus `{ ignorePronounPreference? }` — pass `true` to always get the combined "he/she/it" form regardless of `pronounPreference`. |
| `resolveFieldLabel(key, options?)` | `key` one of `"mood"`, `"person"`, `"subject"`, `"object"` — a field heading, not a value. Returns `{ text, title }`, same shape as `resolveMoodLabel`. |
| `t(key, values?)` | Direct lookup against the active morphology catalog (word-class terms, conjugation labels, gloss/reason fragments). Missing keys return the key itself. GUI chrome catalogs are not shipped. Prefer the resolve\* functions above for conjugation labels. |
| `setActiveLocale(locale)`, `getActiveLocale()` | Get/set the locale (`"en"`/`"da"`/`"kl"`) every `t()` call and the resolve\* functions above resolve against when `options.locale` is omitted. Call `setActiveLocale` first if you want a specific language. Unavailable catalogs fall back to English. |

### Conjugation math — stem derivation, catalog, conjugate

The same functions oq's conjugation widget and Deconstruct reverse-lookup
use. Additive endings only; undocumented citation patterns return `ok: false`
rather than a guessed stem. `analyzeWord` is unchanged.

| Export | Contract |
|---|---|
| `parseEndingEntry(entry)` | One grammarian morpheme → conjugation ending, or `null` if it is not a supported additive verb-mood ending. |
| `buildEndingCatalog(flat)` | Parse a grammarian `flat` array into sorted `ConjugationEnding[]`. Non-array ⇒ `[]`. |
| `catalogMoods(catalog)` | Distinct mood keys in catalog order. |
| `endingsForMood(catalog, mood)` | Endings belonging to one mood. |
| `subjectsForMood(catalog, mood)` | Distinct transitive subjects for a mood; empty for intransitive. |
| `objectsForSubject(catalog, mood, person, number)` | Distinct objects for one subject in a transitive mood. |
| `findEnding(catalog, mood, subjPerson, subjNumber, objPerson, objNumber)` | One cell, or `null`. |
| `matchConjugationEnding(word, catalog)` | Longest unique catalog ending that is a suffix of `word`. `{ ok, stem, ending }`; declines on no match or equal-length ambiguity. |
| `deriveIntransitiveStem(headword)` | Strip documented 3sg `-voq`/`-poq`. Declines doubled-consonant remainders. `{ ok, stem }`. |
| `deriveTransitiveStem(headword, stemHint?)` | Strip documented 3sg/3sg `-aa`/`-vaa`. `stemHint` from katersat is trusted; doubled-consonant remainders decline without it. |
| `deriveSchwaStem(headword)` | Strip documented schwa-stem `-qaaq`. |
| `canConjugate(headword)` | True if any of the three stem derivations succeeds. |
| `conjugate(stem, ending)` | `{ word, approximate }`. `ending` needs `{ id, text }`. Approximate when `OUT_OF_SCOPE_IDS` contains the ending. |
| `conjugateSchwaStem(stem, ending)` | Uses published `allomorphs.schwa_final_stem`. `{ ok: true, word, approximate: true }` or `{ ok: false, word: null }`. |
| `glossSentence(meaning, gloss, person?, number?)` | English-only: fill the ending's trailing `(...V...)` clause with the verb gloss. `null` when unparseable. |
| `moodSubjectSplitKey(mood)` | i18n key for same/different-subject hint (`CAU_SAME` / `CAU_DIFF`), or `null`. |

### Dictionary lookup

Network-backed: these fetch full upstream dictionary JSON on first use and
cache in memory afterwards.

| Export | Contract |
|---|---|
| `DICT_SOURCES`, `KAT_SOURCES` | Source registries to pass to `searchEntries`. |
| `searchEntries(sources, query, options?)` | Async search: `{ results, attributions, failed, errors }`. Options: `{ lang?, match?, rank?, advancedRegex? }`; result entries also expose `surface`, `wordClass`, and `glosses`. Rank/match live here (not in oq's dictionary view). |
| `findExactDictMatch(word)` | Async exact Kalaallisut-headword lookup; first matching entry or `null`. Never throws. |
| `mergeBySurface(entries)` | Homograph merge: collapse same-headword+class entries, unioning glosses. Pure; used by `getAllEntries`. |
| `getAllEntries(sources)` | Cached full-dictionary dump for the given `DICT_SOURCES` (or a subset). Empty until those sources have been loaded. Cloned for the caller. |
| `loadFullSource(src)` | Fetch one `DICT_SOURCES` / `KAT_SOURCES` entry into the in-memory cache. Deduplicates concurrent calls; no-ops if already loaded. Call this before `getAllEntries` / `injectDictionaryRoots` if you have not searched yet. `searchEntries` and `findExactDictMatch` load on their own. |
| `ensureFullLoaded(sources)` | `Promise.allSettled` of `loadFullSource` over `sources`. |
| `randomEntry()` | Random `DICT_SOURCES` entry after loading the full lexicon, or `null`. Cloned. |
| `injectDictionaryRoots(entries, presets?, opts?)` | Synthetic stem presets (`id: dict_…`) from dictionary headwords. No fetch. Skips non-N/V POS and, by default, stems whose `expectedLower` is already in `presets`. `noun_plural_form` gets `continuation_class: WORD_FINAL`. Returns only the new presets — concat onto the catalog yourself. Does not change `analyzeWord`. |
| `suggestWordCompletions(prefix, maxCount?)` | Async dictionary prefix completions (excludes the exact prefix). `{ word, gloss, entry? }[]`. Distinct from `suggestMorphCompletions`. |

### Classification catalogs and katersat hints

Network-backed like dictionary lookup: live fetch on first use (katersat has
no vendored `data-mirror` — GPL-3.0-or-later, live-fetch only). Pass
`options.data` on the loaders to skip the network with caller-supplied JSON.
oq's PWA mirror stays an oq concern.

| Export | Contract |
|---|---|
| `SEMANTIC_CLASSES_URL` | Live katersat `semantic_classes.json` URL. |
| `loadSemanticClasses(options?)` | Warm the catalog. `options.data` is the raw JSON document. Failures resolve to `null`. |
| `getSemanticClasses()` | Cached class list, or `[]` before a successful load. |
| `getSemanticClassByCode(code)` | One class or `null`. |
| `getSemanticClassById(id)` | One class or `null`. |
| `getSemanticClassChildren(id)` | Direct children of `id`, or `[]`. |
| `DOMAINS_URL` | Live katersat `domains.json` URL. |
| `loadDomains(options?)` | Warm the domain catalog. `options.data` is the raw JSON. Failures resolve to `null`. |
| `getDomains()` | Cached domains, or `[]`. |
| `getDomainByCode(code)` | One domain or `null`. |
| `getKatersatByLetterUrl(letter)` | URL of one katersat `by-letter/<letter>.json` shard. |
| `getKatersatTransitiveStemHint(headword, options?)` | Bare stem from katersat `fst_analyses`, or `null`. Live-fetches one letter shard; never throws. |
| `getKatersatGlossHint(headword, options?)` | First published English verb gloss from katersat, or `null`. Live-fetches one letter shard; never throws. |

## Poking at upstream data directly

You don't need `oq` or Node to look at the raw data — the upstream sources
(listed in `src/upstream-sources.js`) are plain JSON files served over
HTTPS, so `curl` + `jq` (and `sqlite3` for anything you want to query
repeatedly) get you there directly. This is not an API `oq` exposes — it's
just how to inspect the same files `oq` fetches.

### Oqaasileriffik dictionary — `curl` + `jq`

Full entry shape: `{id, lexeme, word_class, class_path, stem, gloss_en,
source_file, source_row}`.

```bash
# meta + how many entries
curl -s https://jandahl.github.io/Oqaasileriffik-dicts/all_entries.json \
  | jq '{meta: .meta.attribution, count: (.dictionary_entries | length)}'

# grep for a headword substring, projected to just the fields you probably want
curl -s https://jandahl.github.io/Oqaasileriffik-dicts/all_entries.json \
  | jq '.dictionary_entries[]
        | select(.lexeme | test("qimme"))
        | {lexeme, word_class, gloss_en}'
```

### Katersat lexicon — sharded by letter, so fetch only what you need

Full lexeme shape includes `id, kalaallisut, english, danish, word_class,
semantic_classes, valence, domain, gender, fst_analyses, definition, info,
verb_frames, …` — most of it null/empty for a given entry, so projecting
down to what you actually need keeps output readable.

```bash
# one letter's shard instead of the whole lexicon, just the useful fields
curl -s https://jandahl.github.io/Oqaasileriffik-katersat/by-letter/q.json \
  | jq '.lexemes[] | {kalaallisut, word_class, danish, english}'

# every verb ("v") in a shard, headword + Danish gloss only
curl -s https://jandahl.github.io/Oqaasileriffik-katersat/by-letter/q.json \
  | jq '[.lexemes[] | select(.word_class == "v") | {kalaallisut, danish}]'
```

### Grammarian morphemes (version-pinned) — `curl` + `jq`

Full entry shape (under `.by_id` on the current `GRAMMAR_MORPHEMES_URL`, or
legacy `.flat[]`) nests fields under `lexical_facts`, `application_logic`,
`plain_gloss`, etc. — project the ones you want rather than reading the
whole nested object. JS consumers should call `flattenGrammarianData(data)`
rather than assuming `.flat`.

```bash
curl -s https://jandahl.github.io/jandahl-custom-KAL-grammarian/v2/grammar/morphemes-by-id.json \
  | jq '.meta'

# id, underlying form, English gloss, and category for every morpheme
curl -s https://jandahl.github.io/jandahl-custom-KAL-grammarian/v2/grammar/morphemes-by-id.json \
  | jq '[.by_id[]] | flatten | .[] | {
      id,
      underlying_form: .application_logic.underlying_form,
      gloss: .plain_gloss.en,
      category
    }'
```

### Loading a source into SQLite for repeated querying

Fetching + `jq`-filtering on every query gets slow once you're doing more
than a couple of lookups. `sqlite3`'s JSON extension (built in on recent
versions) can load a fetched file once and let you query it with SQL:

```bash
curl -s https://jandahl.github.io/Oqaasileriffik-dicts/all_entries.json \
  -o /tmp/all_entries.json

sqlite3 :memory: <<'SQL'
.mode json
CREATE TABLE entries AS
  SELECT value ->> 'id'         AS id,
         value ->> 'lexeme'     AS lexeme,
         value ->> 'word_class' AS word_class,
         value ->> 'gloss_en'   AS gloss_en
  FROM json_each(readfile('/tmp/all_entries.json'), '$.dictionary_entries');

SELECT id, lexeme, gloss_en
FROM entries
WHERE lexeme LIKE 'qimme%'
ORDER BY lexeme;
SQL
```

Same pattern works for the katersat lexicon (`$.lexemes` instead of
`$.dictionary_entries`) — swap in whichever source URL and JSON path you
need. `docs/data-mirror/*.json` (see `src/upstream-sources.js`'s `local`
field) are committed same-origin snapshots of some of these sources, so once
`oq` is deployed you can `curl` those instead if you want a pinned,
build-time copy rather than live upstream data.

## Changing the surface

1. Edit the export list in `src/public-api.js` and bump `API_VERSION`.
2. Update the tables above.
3. Update `EXPECTED_SURFACE` in `test/public-api.test.js`.

The snapshot test fails until all three agree — that friction is the point.
