juurio / docs /api.md
samuli's picture
Juurio 0.1.0: code, wheel, model card and licences
9c2cf4c verified
|
Raw History Blame Contribute Delete
19.1 kB
# juurio — API & CLI contract
Open-vocabulary Finnish morphological transforms over raw text. You hand it a sentence and an
operation (`past`, `passive`, `plural`, …); it parses, transforms the finite main clause, and
returns the result or an explicit abstention. The differentiator is **OOV/loan words**: words the
exception lexicon does not cover (`striimata`, `tubettaja`) are inflected by small neural realizers.
## Architecture (the "split thesis")
Three components, each owning what it is best at:
| Concern | Component |
|---|---|
| structure / scope (which clause, which subject/object) | neural UD dependency parser (Giella/Stanza-free) |
| idiosyncrasy-free morphology (known words) | net + lexicon exception table (byte-exact to the Giella FST) |
| open-vocabulary morphology (OOV/loan words) | tiny lemma-anchored neural realizers |
The serving runtime needs only torch/numpy/transformers — no Stanza, no Giella FST, no Voikko (those
are used only to build the models and data). The rule engine owns all non-local structure (agreement propagation, object
case, subject drop); the neural realizers are called **per token**, only when the lexicon has no
entry for the cell.
## Scope boundary (a feature, not a limitation)
Two entry points with deliberately different promises:
- **`transform(op, text)` — finite-clause transforms** that preserve (or deliberately rewrite) the
clause's grammar: negation, tense/mood, passive, imperative, question, number agreement, possessive
on the subject head. Output is a grammatical sentence or an explicit abstain.
- **`realize_np(np, …)` — phrase-level nominal morphology**: inflect an NP into a Case/Number/Px(+
clitic). This is where *arbitrary case* lives, because case is **syntactically governed** — making
it a clause op would produce ungrammatical sentences (`Talo on iso` → `Talossa on iso`).
Anything **semantic** — e.g. existential/locative framing (`Taloissani on koira`), which introduces
and reorders arguments — is **out of scope** for both; it would be a separate future *generator* op,
not a morphological transform. Keeping these apart is what lets each give a clean guarantee.
## Python API
```python
from juurio import Transformer, transform, SUPPORTED_OPS, OP_CATALOG, REASONS, ABSTAIN_REASONS
t = Transformer() # loads parser + models once (heavy; reuse it)
r = t.transform("past", "Koira haukkuu pihalla.")
r.ok # True
r.text # 'Koira haukkui pihalla.'
r.reason # 'ok'
# one-shot convenience (reuses a process-wide Transformer):
transform("plural", "Striimaaja nukkuu.").text # 'Striimaajat nukkuvat.' (OOV noun, neural)
```
`import juurio` is cheap; torch + the neural parser/realizers load lazily on the first
`Transformer()` / `transform()` (no Stanza/Giella at serving time).
### `Transformer.transform(op, text, lemma=None, sent_index=0) -> Result`
- **op** — one of `SUPPORTED_OPS` (else `ValueError`).
- **text** — raw text; a leading dialogue dash/quote is handled and re-prepended.
- **lemma** — override the parser's lemma for the main verb. Use when a loan stem is mis-lemmatized
(`striimaa` → parser says `striä`); a confidence warning is raised automatically otherwise.
- **sent_index** — which parsed sentence to operate on (default 0).
### `Result`
| field | meaning |
|---|---|
| `text` | transformed sentence, or `None` if abstained |
| `ok` | `True` iff a form was produced |
| `reason` | one of `REASONS` (`"ok"` on success, else in `ABSTAIN_REASONS`) |
| `trace` | provenance dict (see below) — `juurio transform --trace` prints it |
| `warnings` | low-confidence-lemma notes + **neural realization** notices (model guess, unverified against the lexicon) |
`trace` fields: `op`, `root_verb`, `parser_lemma`, `lemma_source` (`override`/`parser`); and, when
relevant, `neural_oov` (forms the realizer produced — model guesses), `oov_unrealizable` /
`oov_unverified` (words it could not/did-not-trust). `realize_np` traces `util`, `case`, `number`,
`px`, `clitic`, `head`. Inspect on the CLI with `--trace`, or as JSON with `--json`.
### Performance
Models load once per process; the first call also loads the neural parser (the cold cost).
Indicative numbers (`juurio bench`, CPU parser + GPU realizer):
| | time |
|---|---|
| `Transformer()` construct | ~2 s |
| first `transform` (cold — parser load) | ~9 s |
| `transform` (warm) | ~70 ms |
| `realize_np` (warm) | ~30 ms |
Reuse one `Transformer` (or the module-level `transform`/`realize_np`, which cache a singleton).
## Scope contract
`transform` operates on a **single finite Act/Ind main clause** (≈≤12 words, one finite verb).
Anything else **abstains** (`ok=False`) — it never guesses. Reason codes:
| reason | meaning |
|---|---|
| `ok` | a transformation was produced |
| `parse_empty` | the parser returned no usable sentence at `sent_index` |
| `no_clause` | no finite Act/Ind main clause (subordinate-only, fragment, or already-negated) |
| `out_of_scope` | exceeds scope limits (too long / >1 finite verb / coordination) |
| `op_not_applicable` | op does not apply to this clause's base state (e.g. `past` on a past clause) |
| `oov_unrealizable` | an open-class word needed inflecting but **neither** the lexicon **nor** the neural realizer could produce the target form |
| `oov_unverified` | the neural realizer produced a candidate for an OOV word but it failed validation (stem-inconsistent or near-tie) and was **suppressed**, not emitted |
| `conflicting_features` | `inflect` got two valid but contradictory inputs (spec `mon gen` + `number="sing"`; `proper=True` + a degree; `like=` + an explicit case/number/degree; a single-word control on a phrase) |
| `unanalyzable_model` | `inflect(like=…)` could not analyze the model word into a nominal case/number cell to copy |
**Neural OOV is surfaced, never silent.** When a form is produced by the neural realizer (not the
lexicon), it appears in `trace["neural_unverified"]` and a `warnings` entry. Treat it as a model guess.
## Operations
`SUPPORTED_OPS` is the flat tuple; `OP_CATALOG` groups them with descriptions. Summary:
- **finite verb** — `neg`, `past`, `pres`, `cond`, `perfect`, `potential`, `passive`
- **compound tenses** (analytic olla auxiliary + the perfect participle) — `pluperfect`
(`olin sanonut`), `cond_perfect` (`olisin sanonut`), `pot_perfect` (`lienen sanonut`); OOV verbs
reuse the trained `perfect` participle + the lexicon's `olla` auxiliary
- **composites** (applied atomically) — `neg+past`, `neg+cond`, `neg+perfect`, `passive+past`,
`passive+cond`, `passive+perfect`, `neg+passive`, `neg+passive+past`, `neg+passive+cond`,
`neg+passive+perfect`
- **clause** — `quest` (polar question)
- **imperative paradigm** (drop subject, total object → Nom; negative forces partitive; trailing
punctuation preserved) — `imperative` (Sg2, OOV-capable), `imperative_pl2/sg3/pl1/pl3`, and the
negative `älä`-series `neg_imperative` (Sg2, OOV-capable), `neg_imperative_pl2/sg3/pl1/pl3`.
**Scope:** applies only when the command's addressee is in the clause — a subjectless/already-imperative
source (`osta talo.`) or a 2nd-person subject (`sinä/te`); a 3rd/1st-person subject abstains
(`out_of_scope`), since turning *"the man buys a house"* into a command would invent an addressee.
- **number** (re-agrees subject NP, modifiers, finite verb, copula predicate) — `plural`, `singular`
- **degree** (predicate adjective) — `comp`, `super`
- **enclitics** (on the finite verb) — `clitic_kin`, `clitic_kaan`, `clitic_han`, `clitic_pa`
- **possessive suffix** on the subject head, preserving its case/number — `poss_1sg`, `poss_2sg`,
`poss_3`, `poss_1pl`, `poss_2pl` (`talo`→`taloni`, `talossa`→`talossani`; OOV `striimaaja`→`striimaajani`)
`juurio list-ops` prints the live catalog.
### Combined nominal morphology
Nominal ops compose with `+`: a `+`-join of **nominal atoms** (`plural`/`singular`, `poss_*`,
`clitic_*`) is applied to the subject head as **one realization** with the final
Number+Case+Px, then the clitic is attached — deterministic surface order `stem+case+number+Px+clitic`.
This is correct-by-construction (you cannot re-pluralize the already-realized string `taloni`).
Number changes also re-agree modifiers and the finite verb.
```
poss_1sg+clitic_kin Koira haukkuu pihalla. -> Koiranikin haukkuu pihalla.
plural+poss_1sg Koira haukkuu pihalla. -> Koirani haukkuvat pihalla.
plural+poss_1sg+clitic_kin Koira haukkuu pihalla. -> Koiranikin haukkuvat pihalla.
plural+poss_1sg Striimaaja nukkuu. -> Striimaajani nukkuvat. (OOV head, neural)
```
Each dimension may appear at most once. `+`-composed **verb** ops (`neg+passive+past`) are separate,
pre-registered atoms in the catalog. Mixed verb+nominal composition is not supported.
## Phrase-level nominal morphology: `realize_np`
Finnish case is **syntactically governed**, so arbitrary case is *not* a clause transform (a
subject-case change would be ungrammatical). Instead it is a phrase utility, separate from
`transform`, which keeps grammaticality guarantees:
```python
from juurio import realize_np
realize_np("talo", case="Ine", number="Plur", px="1Sg").text # 'taloissani'
realize_np("iso talo", case="Ine", number="Plur").text # 'isoissa taloissa'
realize_np("Helsinki", case="Ine").text # 'Helsingissä'
realize_np("striimaaja", case="Ade", number="Plur", px="1Sg", clitic="kin").text # 'striimaajillanikin' (OOV)
```
`realize_np(np, case, number, px, clitic)` inflects an NP — a bare lemma or a short `adj* noun`
phrase — into the target Case/Number with an optional possessive and enclitic, agreeing modifiers,
and per-token lexicon→neural fallback (surface order `stem+case+number+Px+clitic`). It returns a `Result`
(`ok` with the NP, or an abstain reason). This is the showcase for open-vocabulary nominal
morphology; `transform` is for grammaticality-preserving clause operations. Sentence-level locatives
(`Taloissani on koira`) are a separate future generator op, not a case transform.
## Phrase-level verb morphology: `realize_verbform`
The non-finite counterpart of `realize_np`: inflect a VERB into a **participle** or **infinitive**.
The four participles inflect like adjectives in case+number; the 2nd/3rd infinitives are case-marked.
A second open-vocabulary surface — the lexicon handles known verbs, a small **verbform realizer**
inflects OOV/loan verbs (`striimata`), flagged `unverified`.
```python
from juurio import realize_verbform # form ∈ {va, nut, tava, tu, inf2, inf2_pass, inf3, ma}
realize_verbform("lukea", form="va").text # 'lukeva' (present active participle)
realize_verbform("lukea", form="va", case="Ine").text # 'lukevassa'
realize_verbform("lukea", form="tu").text # 'luettu' (past passive participle)
realize_verbform("mennä", form="inf3", case="Ade").text # 'menemällä' (3rd inf adessive)
realize_verbform("mennä", form="inf3", case="Ine").text # 'menemässä'
realize_verbform("tehdä", form="ma", case="Ine").text # 'tekemässä' (agent participle)
realize_verbform("tehdä", form="inf2_pass").text # 'tehtäessä' (passive 2nd inf, temporal)
realize_verbform("kuulla", form="tu", case="Par", px="3").text # 'kuultuansa' (temporal: 'after hearing')
realize_verbform("striimata", form="va").text # 'striimaava' (OOV verb, neural)
```
`va`/`nut`/`tava`/`tu` = present/past active/passive participle; `inf2`/`inf3` = 2nd/3rd infinitive;
`inf2_pass` = passive 2nd infinitive (`-ttaessa` temporal, "while X is being done"; inessive-only,
case-invariant); `ma` = agent participle (-ma/-mä, "the X that someone does", inflects in case/number).
`px` (one of `None`/`1Sg`/`2Sg`/`3`/`1Pl`/`2Pl`) attaches a possessive suffix — participles inflect
like nominals, so `tu`+`Par`+`px` realizes the **temporal construction of anteriority** (`kuultuaan` /
the `-nsa` variant `kuultuansa` = "after hearing"). For OOV verbs the bare participle comes from the
verbform realizer and the case+Px from the noun realizer (both neural stages flagged unverified).
Returns a `Result` (`ok` with the form, or `oov_unrealizable`/`oov_unverified` when the morphology
engine cannot produce/trust it). Like `realize_np`, this is a phrase utility, not a clause transform.
## Friendly inflection: `inflect`
The ergonomic single-word front door over `realize_np` — human-readable case/number/degree names, a
proper-noun toggle, and **inflect-by-example**. Returns a `Result` (never a bare string: abstention
stays first-class).
```python
from juurio import inflect
inflect("talo", "gen").text # 'talon' (alias: gen/genitive/genetiivi)
inflect("talo", "mon gen").text # 'talojen' (combined spec, taivutin-style)
inflect("talo", case="inessive", number="plural").text # 'taloissa'
inflect("talo", px="1sg").text # 'taloni'
inflect("kaunis", "gen", degree="comp").text # 'kauniimman' (degree => adjective path)
inflect("meri", "gen").text # 'meren' (common noun)
inflect("Meri", "gen", proper=True).text # proper-noun reading (≠ 'meren')
inflect("makaronilaatikko", like="talojen").text # 'makaronilaatikkojen' (copy a model word's cell)
inflect("iso talo", "mon gen").text # 'isojen talojen' (phrase -> realize_np)
```
`inflect(word, case="nom", number="sing", *, degree="pos", px="none", clitic=None, proper=None,
pos=None, like=None)`:
- **case** — a case alias, *or* a combined spec (`"mon gen"`, `"plural genitive comparative"`).
Accepts English abbrev/full and Finnish (`gen`/`genitive`/`genetiivi`, `mon`/`monikko`).
- **number / degree** — aliases too; an explicit value that contradicts the combined spec abstains
(`conflicting_features`).
- **px / clitic** — possessive suffix (`1sg`…/`None`) and enclitic, as in `realize_np`. `px=None` is
accepted (= no suffix). Rejected on a single adjective target.
- **proper** — `True`/`False` forces PROPN/NOUN; `None` (default) infers PROPN from a leading capital.
- **pos** — explicit `'noun'`/`'proper'`/`'adjective'` (wins over inference). PRON/NUM are not yet a
first-class `pos`. A degree implies the adjective path when `pos` is unset.
- **like** — a model word; its analyzed case (+number, +degree if adjectival) is copied onto `word`.
Mutually exclusive with `case`/`number`/`degree`; an unanalyzable model abstains
(`unanalyzable_model`).
An **unknown alias** raises `ValueError` (a typo fails fast); a **contradiction** abstains
(`conflicting_features`). A single token is realized **parser-free** (so `proper`/`pos` are honored);
a whitespace phrase delegates to `realize_np` (which applies `px`/`clitic` to the head but rejects the
single-word controls `proper`/`pos`/`degree`). An empty `word` raises `ValueError`.
## Analysis: `analyze` / `lemmatize`
The inverse of generation (what a generate-only inflector cannot do): the serving neural parser
exposed directly.
```python
from juurio import analyze, lemmatize, TokenAnalysis
sents = analyze("Koirat haukkuvat pihalla.") # list[list[TokenAnalysis]] (one list per sentence)
tok = sents[0][0]
tok.lemma, tok.upos, tok.feats # 'koira', 'NOUN', {'Case': 'Nom', 'Number': 'Plur'}
tok.asdict() # plain dict (id/form/lemma/upos/head/deprel/feats)
lemmatize("Koirat haukkuvat pihalla.") # ['koira', 'haukkua', 'piha', '.'] (flat)
```
`TokenAnalysis` is a frozen record (`id`, `form`, `lemma`, `upos`, `head` — 1-based, 0=root —,
`deprel`, `feats` as a read-only UD mapping). `analyze` keeps sentence boundaries; `lemmatize`
returns a flat lemma list.
## CLI
Each library function has a like-named command (the CLI is a thin wrapper over the same
`Transformer`); `realize_np`/`realize_verbform` keep the hyphenated spelling.
```
# one command per library function
juurio transform <op> "<text>" [--lemma L] [--trace] [--json] # transform()
juurio inflect "<word>" [spec…] [--case C] [--number N] [--degree D] [--px P] [--clitic K] [--pos P] [--like W] [--proper] [--json] # inflect()
juurio analyze "<text>" [--json] # analyze()
juurio lemmatize "<text>" [--json] # lemmatize()
juurio realize-np "<np>" [--case Ine] [--number Plur] [--px 1Sg] [--clitic kin] [--json] # realize_np()
juurio realize-verbform "<verb>" [--form va|nut|tava|tu|inf2|inf2_pass|inf3|ma] [--case Ine] [--number Plur] [--px 3] # realize_verbform()
# infrastructure (no library counterpart)
juurio list-ops
juurio batch <op> [--lemma L] [--json] < lines # one op for every stdin line
juurio batch [--json] < tsv # per-line "op<TAB>text[<TAB>lemma]"
juurio demo # curated tour (incl. OOV) — also a smoke test
juurio repl # interactive shell; model loaded once
```
`inflect` accepts a human case/number/degree phrase positionally (`juurio inflect talo mon gen`
→ `talojen`, `juurio inflect kaunis gen --degree comp`), or as `--case/--number/--degree` flags;
a positional phrase wins over the flags. `--proper`/`--no-proper` force/forbid a proper-noun reading.
`juurio repl` is a read-eval loop over every feature (model loaded once): `inflect talo mon gen`,
`inflect makaronilaatikko like=talojen`, `transform passive Mies osti talon.`, `analyze …`,
`lemmatize …`, `np …`, `verb …`, `ops`, `help`. Options are `key=value` (`px=1sg proper=true
pos=adjective clitic=kin like=talojen`).
Also `python -m juurio …`. Without `--json`, prints the result (warnings appended after `⚠`) and
sends abstentions to stderr as `[abstain: <reason>] <input>`. With `--json`, one JSON object per
line (`text/ok/reason/warnings/trace`). **Exit status:** `0` if every transform produced a form,
`2` if any abstained — so scripts can branch on it.
```console
$ juurio transform plural "Striimaaja nukkuu."
Striimaajat nukkuvat. ⚠ neural realization (unverified by FST): ['striimaaja->Striimaajat (NOUN)']
```
## Quality
The package is designed to **abstain rather than guess**: out-of-scope inputs return an explicit
abstention (`ok=False`) with a reason code instead of a speculative form, and neural OOV realizations
are always surfaced (in `trace["neural_unverified"]` and `warnings`) rather than emitted silently. Known
words are inflected to be byte-exact to the reference FST (through the exception lexicon); OOV/loan words are inflected by the neural
realizers, with stem-inconsistent or near-tie candidates suppressed (`oov_unverified`) rather than
returned.
Run `juurio demo` for a curated tour (including OOV cases) that doubles as a smoke test.