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.