Skip to content

Latest commit

 

History

History
90 lines (68 loc) · 17 KB

File metadata and controls

90 lines (68 loc) · 17 KB

API (CONTRACT)

What this is: the one API, described once — the same calls exist with the same behaviour in Rust, WebAssembly/TypeScript, and C, so this page answers three questions for any call: which feature does it need, how fast is it, and exactly what happens when that feature is missing (short version: a structured Unsupported error that names the gap — the engine never guesses and never panics). The semantics paragraphs after the table record the finer rulings, each tagged so any behaviour can be traced to the ruling that made it. Changes to this contract are written down with their measured effect.

Error model

enum LexError {
  Unsupported { call: &'static str, feature: &'static str, gap: Gap }, // Gap = Pack | Engine | Both (D-004)
  BadPack     { reason: String },   // open()/open_verified() only
  BadInput    { reason: String },   // caller error: invalid regex, unknown id, non-UTF-8 lexicon key, oversize input…
}

Never panic across a boundary. wasm throws { code: "Unsupported"|"BadPack"|"BadInput", call, feature?, gap?, message }call is set on every error (the host-invoked call name), gap is lowercase "pack"|"engine"|"both" and present only with code: "Unsupported". C ABI returns negative codes (-1 Unsupported, -2 BadPack, -3 BadInput) with lex_last_error() for the message. The gap field says whether the missing feature is absent from the pack, the engine build, or both.

JS-surface shapes (D-007): tuples become named objects (prefix returns PrefixHit[] = {word, id}[]); limit parameters are u32 with 0xFFFFFFFF as the unlimited idiom; manifest() returns the pack's manifest bytes verbatim (bindings may slice them straight from the header rather than re-serialise).

Calls that can fail per-item return Result: word(id) is Result<String> (BadInput on unknown id, and on a non-UTF-8 lexicon key — a pack defect surfaced lazily, since validating every key at open() would spend the cold-init budget). prefix has no error channel and silently skips non-UTF-8 keys. engine_features() lists implemented features, not declared Cargo flags — a name appears only when its code can actually serve calls, so gap reports are never lies.

Inputs are NFC-normalised UTF-8; the engine does not case-fold (packs store the casing policy applied at build).

Calls

Complexity: n = lexicon size, k = result count, |w| = input length. Legend for absent: U = returns Unsupported.

call feature complexity absent
open, open_verified, features, engine_features, manifest verify is O(pack)
lookup(word) -> Option<u32> lexicon O(|w|) pack invalid without it
word(id) -> String lexicon O(stride) via a lazy rank index (D-011): first word/synonyms call builds it (~8 ms / 47k words, 17.5 KB resident, stride 32); packs whose ids are not FST ranks fall back to the O(id) walk
prefix(p, limit) -> Vec<(String,u32)> lexicon O(|p| + k)
is_word(word) -> bool lexicon = lookup().is_some()
tag(id, name) / ids_with_tag(name, value?) tags.<name> O(log t) / O(t) U
lemma(id) -> u32 inflect O(log n) identity, not U (spec §3)
fuzzy(word, d, limit) -> Vec<FuzzyMatch> fuzzy O(automaton ∩ FST); d ≤ 2, BadInput above U
suggest(word, limit) -> Vec<FuzzyMatch> fuzzy fuzzy ∪ variants ∪ keyboard; ranked by the pack's rank-linear-v1 model when the manifest names one with role: "suggest" (D-045), else by (distance, freq, bytes) U; degraded rank without freq/variants; hand rank without a ranker
suggest_with(word, limit, rank) -> Vec<Suggestion> fuzzy suggest with the order chosen (`RankWith::Auto Hand
load_ranker(name, bytes), ranker_names(), suggest_ranker(), ranker_weights(name) fuzzy hold a .lexrk by name for RankWith::Model; report what is loaded, what the pack ranks with by default, a model's weights U; load_ranker errors on pack-hash / arch / size mismatch (BadInput)
query(spec) -> Vec<u32> query one FST traversal ∧ post-filters U
rule_search(rule, seed) query.regex+mutate inverse rewrite ∩ FST, forward-validated (D-025) U
synsets(id) / lemmas(sid) synsets O(1) CSR row U
related(sid, rel) relations.<rel> O(1) CSR row U per relation
synonyms(word, opts) synsets O(rows touched); ranked (D-044) U
synonyms_scored(word, opts) -> Vec<SynonymMatch> synsets as synonyms, with the evidence per result (D-044) U
similar(word, opts) -> Vec<SimilarMatch> synsets (+ relations.*, variants, phon, fuzzy, freq enrich) O(capped fan-out + candidates); < 2000 µs warm U; every enriching source absent is skipped silently (D-044)
pos(sid) / lexname(sid) / gloss(sid) pos/lexname/gloss O(1) U
mutate(seeds, opts) / mutate_many mutate + ≥1 rules.<set> O(candidates · passes) U
phon_score/key/keys/similar phon O(|w|) / O(|w|) / O(|w|) / O(n) U
domain_check(text, tld) domain O(|w|); empty tld derives the longest known label-suffix (the original's domainDetail behaviour); no domain.tlds segment → served with tld_known: false U
hdc_vec/similar/score/scores/best, crystal_new/add/export/load, load_crystal(name, bytes), crystal_names() hdc O(D·|w|) / O(n·D/64) / O(D/64) U; loads error on pack-hash/dimension/seed mismatch (BadInput); load_crystal holds by name for rank (D-029)
score(text, model) score + model.<name> model-dependent, < 500 µs U
parse(grammar, text) -> Slots grammar.<name> (built-ins: datetime, quantity, command — code-only, each its own feature id) O(|text|), measured 0.8–3.3 µs; no clock — relative phrases return relations (days_offset: 1), the host applies "now"; unparseable text is matched: false, never an error; dates are day-first, two-digit years rejected (a century pivot needs a clock); D-032 ratifies the slot families (*_offset, direction, verbatim object, surface-token unit) U per grammar
canonicalize(text) lexicon (+inflect, synsets enrich) O(tokens · |w|) enrichments omitted, not U
compress/decompress compress O(tokens) U
batch(ops) per op Σ ops per-op results; one failure does not abort the batch

Graceful degradation inside a supported call (e.g. suggest without freq, mutate annotate without phon) never errors — the manifest tells the host what it got.

Ordering guarantees

All result lists have a deterministic total order: fuzzy (distance, then freq desc, then word bytes); suggest the same, except that when the pack carries a role: "suggest" model the first key is that model's integer score descending and the three hand keys break its ties (D-045); mutate (input index, rule id, pass, then text bytes); synonyms, synonyms_scored and similar (score descending, then word bytes — D-044, below); everything else (id or FST order). Same pack + same engine version + same call ⇒ same bytes out. The three ranked graph calls compute their scores in integer thousandths and only then convert to f32, so native and wasm agree bit for bit.

FuzzyMatch = { word, id, distance, freq } (freq 0 when the pack has no freq segment). Suggestion = { hit: FuzzyMatch, score: Option<f32>, dot: i64, features: [i32; 16] } (score and dot are None/0 under the hand rank; features are always computed — docs/scorer.md, rank-linear-v1). fuzzy measures plain Levenshtein edits — the name is the meaning. D-006 (c) promised suggest a transposition-as-one-edit automaton; that never landed, and D-045 supersedes it: suggest's candidate search is the same plain-Levenshtein automaton as fuzzy, and the transposition signal lives in the ranker's damerau feature, where the trained weight on it is what puts receive ahead of relieve for recieve. JS mirrors the surface as suggestExplained(word, limit, "auto" | "hand" | "model:<name>"), loadRanker, rankerNames, suggestRanker, rankerWeights, rankFeatureNames() and rankFeatures(...).

Graph semantics (D-008): synonyms' hops counts levels — level 1 is the word's own synsets, so hops: 0 and 1 are identical; each unit above 1 walks one relations.similar edge; a pack without that relation serves hops > 1 at level 1, silently. engine_features() never lists relations.* (one code body serves the open-ended set; related's gap derives from whether graph code is compiled). related with a relation outside the ten catalogue types is served normally when the pack carries it, but its Unsupported reports the bare feature "relations".

Synonym ranking (D-044): synonyms returns the same candidate set it always did — the word's synsets, widened by hops, narrowed by the filters — but ordered by a fixed score instead of by word id. In thousandths of a point, a candidate scores 1000 >> (level − 1) per synset it shares with the walk (1000 for one of the query's own senses, 500 one relations.similar edge out, 250 two out…), +100 if one of those synsets has the query's dominant part of speech (the most common pos byte across the query's own synsets, satellite adjectives folded into adjectives, lowest byte winning a tie), +100 for its dominant lexicographer file the same way, +200 / n for a candidate in n synsets (specificity: a word whose only sense is this one is about it), and + Zipf byte / 4 (at most 63) as the last tie-breaker. The four tie-breakers sum to at most 463, under the 500 a level-2 sense is worth, so they only ever order candidates whose sense evidence is equal and can never move one past a candidate with more of it. Sense evidence itself accumulates across synsets, so a candidate in two of the query's similar-to senses can outscore one that shares a single sense — at hops 2 on hot, heated (two neighbouring senses) precedes live (one shared sense out of twenty-one). Level is therefore a strong signal, not a hard partition; SynonymMatch.level reports it so a host that wants the partition can apply it. Any term whose table the pack lacks (pos, lexname, freq) is simply absent — a ranking signal degrades where a filter would error. synonyms_scored returns SynonymMatch = { word, id, score, shared_senses, level, synset }: score in points, shared_senses the number of the query's own synsets the word is in, level the nearest level it was reached at, synset the lowest-numbered synset at that level holding both words. Measured on en-GB-full (T1, 784 queries from the 392 human-confirmed pairs): MRR 0.664 against 0.641 for the old id order, partner-first 52.2 % against 49.5 %; synonyms("hot") now leads with spicy, raging, blistering, live where it led with blistering. OEWN's own sense order is not used: the builder does not preserve it, and a first-sense hint segment was priced at 21,273 multi-sense words × ≥ 5 B ≈ 106–170 KB raw on full and not taken this round.

Similar (D-044): similar(word, { limit }) -> Vec<SimilarMatch> answers "words like this one" by blending every signal the pack carries. SimilarMatch = { word, id, score, via: Vec<Via> }, where via lists the distinct kinds of evidence that reached the word, in a fixed order, and score is the sum of their weights plus the specificity and frequency tie-breakers above. The kinds and weights, in points: Synonym 1.0 (shares a synset) · Variant 0.9 (variants row) · SimilarTo 0.6 (relations.similar) · SeeAlso 0.4 (relations.also) · Sibling 0.35 (co-hyponym: shares a hypernym, relations.hypernym + relations.hyponym) · Broader 0.3 (member of a hypernym) · Derived 0.3 (shares a stem after stripping up to two of a fixed list of English suffixes and normalising the seam — quick/quickly/quickness; stems under four bytes never match, so car does not claim carer; this stands in for OEWN's sense-level derivation, which the pack cannot carry, D-008 h) · Narrower 0.25 (member of a hyponym) · SoundsLike 0.15 (shares a Double Metaphone key, phon) · LooksLike 0.15 (within one edit, fuzzy). Wire names are the snake-case forms (similar_to, sounds_like…). This is a ranking call, not a filter: a source the pack lacks (no relations.also, no variants, no freq) or the build lacks (phon, fuzzy, variants not compiled) is skipped silently; only the graph itself is required, and its absence is Unsupported { feature: "synsets" } exactly as for synonyms. Fan-out is capped so the co-hyponym walk stays bounded: at most 64 sibling synsets and 64 hyponym synsets per query sense, taken from the front of each ascending row, and at most 128 lexicon entries read by the stem walk. Measured on en-GB-full at limit 20 over the T1 synonym mix: p50 319 µs, p95 1,361 µs against the 2,000 µs budget; 50 of 50 sanity queries answered, all 50 spanning at least two kinds of evidence.

Query semantics (D-010): regex matches the whole word, anchored both ends, ASCII character classes only ((?-u:\w), [0-9]; Unicode classes are BadInput — a smallness call, loosenable later). This language is enforced by query's own pattern validation, not by which regex backend the build happens to compile (D-015: feature unification with mutate must not widen query's language). tags is a conjunction of {name, value?} predicates mirroring ids_with_tag; results are id order (= FST order per pack-format §5), limit cuts mid-traversal. An explicit filter the pack cannot serve is Unsupported (min_freq without freq errors) — unlike suggest, whose ranking signal degrades silently on the same absent segment: filters change which results exist, ranking only orders them, so filters must never silently no-op. An empty spec is BadInput, not the whole lexicon.

Rule search (D-025): rule_search(rule, seed) answers "which real words could this rule have turned into the seed?" — the seed is treated as an already-mutated form and the call finds its pre-images in the lexicon. This is the obfuscation-normalisation primitive: with the leet rule ate → 8, rule_search("sms.0", "l8") finds late. Mechanics: each occurrence of the rule's replacement text in the seed expands into an alternation of (replacement | original pattern), the resulting whole-word regex is intersected with the lexicon FST, and every candidate is then validated forward — the rule is applied to it and only candidates that actually produce the seed are returned (so precision is exact; recall is documented as approximate for exotic patterns). Rules whose replacement contains group references have no well-defined inverse and return BadInput saying so. Results in id order.

Mutate semantics (D-015, annotation D-029): rule flags are honoured JS-style (g global vs first-match, i matching-only, u no-op, others rejected at compile); replacement dialect is ${n} only; multipass ≤ 4 (BadInput above, 0 ≡ 1); opts carry suffixes: Vec<String> (stage 3, seed-decoration only), sets (rule-set selection), annotate: bool (fills phon = pronounceability and hdc = form similarity — trigram vectors of candidate and seed on both sides, D-029 — each Some only when its feature serves), and rank: Option<"crystal:<name>"> (sorts by the named loaded crystal's first-label score and fills score; unloaded name = BadInput; the label-qualified crystal:<name>:<label> form is a backlogged extension) — only mash remains omitted-until-implemented; rule locale/weight/note are validated metadata, not runtime filters (builders filter by locale); ordering (input index, rule id, pass, text bytes) with first-producer-wins dedup (earliest pass, then smallest rule id). A mutate-compiled build additionally rejects packs whose rule tables do not compile (BadPack naming the rule id) — open()'s structural checks are the floor, not the ceiling.

Appendix: lexname table (frozen, D-008 — verified against the full OEWN 2024 corpus)

 0 adj.all         9 noun.cognition     18 noun.person     27 noun.substance      36 verb.creation
 1 adj.pert       10 noun.communication 19 noun.phenomenon 28 noun.time           37 verb.emotion
 2 adv.all        11 noun.event         20 noun.plant      29 verb.body           38 verb.motion
 3 noun.Tops      12 noun.feeling       21 noun.possession 30 verb.change         39 verb.perception
 4 noun.act       13 noun.food          22 noun.process    31 verb.cognition      40 verb.possession
 5 noun.animal    14 noun.group         23 noun.quantity   32 verb.communication  41 verb.social
 6 noun.artifact  15 noun.location      24 noun.relation   33 verb.competition    42 verb.stative
 7 noun.attribute 16 noun.motive        25 noun.shape      34 verb.consumption    43 verb.weather
 8 noun.body      17 noun.object        26 noun.state      35 verb.contact        44 adj.ppl