File size: 19,078 Bytes
9c2cf4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | # 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.
|