Skip to content

OPENNLP-1877: Static embedding engine for modern distilled embedding tables (opennlp-embeddings) - #1152

Draft
krickert wants to merge 51 commits into
apache:sentencepiecefrom
ai-pipestream:static-embeddings
Draft

OPENNLP-1877: Static embedding engine for modern distilled embedding tables (opennlp-embeddings)#1152
krickert wants to merge 51 commits into
apache:sentencepiecefrom
ai-pipestream:static-embeddings

Conversation

@krickert

@krickert krickert commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1165 (sentencepiece). This PR is based on the sentencepiece branch so the SentencePiece models load; merge #1165 first, then retarget this PR to main.

Adds a new opennlp-extensions/opennlp-embeddings module: a pure-JVM engine for modern static embedding tables (Model2Vec-family distillations, the 2024/2025 successors to word2vec/GloVe: same flat per-token table shape, sentence-transformer semantics, inference is pure lookup).

Positioning: this is the speed tier of text embedding, not a replacement for anything. The ONNX SentenceVectorsDL in opennlp-dl runs the actual transformer and produces contextual vectors, so this isn't made to be a replacement as that's the OOTB accuracy tier.

This module trades that context for a static table: no native runtime, no GPU, and throughput in the hundreds of thousands of texts per second per core. Different pipeline stages want different points on that curve. Matches the philosophy of this style of embedding.

What's in it:

  • TextEmbedder (new interface in opennlp-api, package opennlp.tools.embeddings): the text-level embedding contract. embed(CharSequence), embedAll(List) (default implementation loops; runtimes that batch efficiently should override), and dimension(). It is the text-level counterpart of the existing word-level WordVectorTable, and the javadoc states that layer difference explicitly. StaticEmbeddingModel is the first implementation. SentenceVectorsDL is the natural second one: adopting the interface is purely additive (its existing constructors and getVectors are untouched), and its ONNX runtime is exactly what the overridable embedAll batch method exists for. That adoption is a separate discussion, not part of this PR.
  • SafetensorsFile: reads the safetensors format with a purpose-built cursor parser for the JSON header (no third-party JSON dependency; decodes F32, F16, and BF16, widening the 16-bit types to float). safetensors carries no executable content, unlike pickle-based checkpoints, so loading is safe by construction. The embedding matrix is auto-detected as the single 2-D float tensor, failing loud and listing candidates on ambiguity rather than guessing a key-name convention.
  • WordpieceVocabulary: BERT-style vocab.txt, line number = embedding row id. (Named to match the existing WordpieceTokenizer casing.)
  • StaticEmbeddingModel: embeds through the existing BertTokenizer/WordpieceTokenizer, reused unchanged. The pooling formula is verified against the Moust reference implementations:[CLS]/[SEP] never pooled, unknown tokens dropped from sum and denominator, optional per-row weights tensor, token-count denominator, epsilon-floored normalization.
  • Convenience surface: similarity, mostSimilar (bounded top-K over precomputed row norms), analogy (input
    terms excluded by folding through the mode

Posture: code only, bring your own tab fetched at build or run time, no new dependencies.

Thread safety: immutable, @ThreadSafe, with an 8-thread concurrency test comparing every result against the
single-threaded reference.

Measured (JMH, opt-in jmh profile matching opennlp-runtime's pattern; fixture at real published-table scale, 29,528 x 256): embed ~766k short sentences/s on one core (1.04M ops/s of 5 sentences at 32 threads); full-vocabulary top-10 scan 649/s per core, ~9.2k/s at 32 threads.

Verification: opennlp-embeddings 43/0 plus the new interface contract test, mvn verify green including checkstyle and forbiddenapis.

Follow-ups (deliberately out of this PR): TextEmbedder with a real batchedembedAll (pending discussion with its author), the gRPC backend in opennlp-sandbox, a concurrent-load comparison against a Python baseline, an ANN index for mostSimilar, and bundled-default-model license diligence.

https://issues.apache.org/jira/browse/OPEN-1877

@mawiesne
mawiesne marked this pull request as draft July 8, 2026 04:32
@krickert
krickert force-pushed the static-embeddings branch 2 times, most recently from 2085efd to 7f6f67f Compare July 8, 2026 14:44
@krickert
krickert marked this pull request as ready for review July 8, 2026 15:00
@krickert
krickert force-pushed the static-embeddings branch 2 times, most recently from e60bdba to 7f6f67f Compare July 8, 2026 19:05
@jzonthemtn

Copy link
Copy Markdown
Contributor

This probably requires an update to the docs? Probably doesn't need to be extensive - just a description of when to use it and how.

@krickert

krickert commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

This probably requires an update to the docs? Probably doesn't need to be extensive - just a description of when to use it and how.

I'll do that now

@krickert
krickert force-pushed the static-embeddings branch from 7f6f67f to e60bdba Compare July 9, 2026 20:02
@krickert

krickert commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Done in 76d58c5: a Static Embeddings chapter in the Dev Manual (when to use a static table over a contextual model, both load calls, thread safety, the no-bundled-model license note) plus a module README with the same ground covered for people landing in the source tree.

@rzo1

rzo1 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Hi @krickert - as mentioned on Slack I currently dont have the time for a manual review but I just let Fable do a comprehensive review on this PR. Here is the result:

Blocking / should be addressed before merge:

  • opennlp-embeddings/src/main/java/opennlp/embeddings/TensorInfo.java: The public record exposes its mutable int[] shape component directly, so a caller doing tensorInfo(name).shape()[0] = 0 corrupts the metadata that readFloat32() and StaticEmbeddingModel.load() later validate against. The record-generated equals/hashCode also compare the array by identity, which is surprising for exported API. shape() should return a defensive copy (and the constructor copy its argument), or the component should be an immutable representation, with equals/hashCode overridden accordingly.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/SafetensorsFile.java: The new public API (StaticEmbeddingModel, SafetensorsFile, TensorInfo, Neighbor) lacks the @Experimental marker the codebase applies to the analogous WordVectorTable API. Once released in 3.0.0 the safetensors parsing surface becomes a compatibility commitment even though the PR lists follow-ups (ANN index, gRPC provider) likely to reshape it. Either annotate the new public classes @Experimental or narrow SafetensorsFile/TensorInfo to package-private until the surface settles.
  • opennlp-extensions/pom.xml / opennlp-distr: The new module is not added as a dependency in opennlp-distr/pom.xml nor given an apidocs fileSet in opennlp-distr/src/main/assembly/bin.xml, unlike morfologik, uima and spellcheck (see Include opennlp-spellcheck extension in opennlp-distr aggregator #1115 and Include APIdocs in assembly of binary distr #1116). The binary distribution will ship neither the jar nor the javadoc despite the new manual chapter documenting the module.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/WordPieceVocabulary.java: No dedicated test class, and the fromLines helper's stated purpose (letting tests build a vocabulary from in-memory lines) is unused by any test. The documented duplicate-token rejection, the -1 sentinel of id(), and token(int) bounds are all untested. Please add a WordPieceVocabularyTest (which would also justify fromLines) or drop fromLines and its stale comment.

Minor:

  • opennlp-embeddings/src/main/java/opennlp/embeddings/JsonCursor.java: parseUnicodeEscape accepts signed hex because Integer.parseInt(hex, 16) tolerates a leading +/-, so \u-0FF is silently decoded to a wrong character instead of being rejected. Validate the four characters are hex digits before parsing.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/JsonCursor.java: skipValue's number branch accepts malformed tokens (a lone -, or junk like 1e++--..5) as valid numbers, contradicting the documented fail-loud contract for malformed input.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/TensorInfo.java: elementCount() can overflow long for tensors with three or more large dimensions, and a wrapped small positive value can slip past readFloat32's validation for crafted headers. Use Math.multiplyExact-style checking.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: The load methods throw UncheckedIOException/IllegalArgumentException instead of the checked IOException that every other OpenNLP model/resource loader declares (e.g. Glove, BaseModel-style constructors). This is a lasting API-shape decision that should be made deliberately before release.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: load(Path, Path, boolean lowerCase, boolean normalize) takes two adjacent positional boolean flags; swapping them produces silently wrong embeddings. Consider an options/builder parameter or two-value enums before the API ships.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: embed() pays a redundant second full isolatePunctuation pass per call because BertTokenizer.normalize already isolates punctuation and WordpieceTokenizer.tokenize re-runs it. Results are correct (the pass is idempotent), and the code lives in the reused opennlp-api tokenizers, but it is an optimization opportunity on the module's hot path.
  • opennlp-embeddings/src/test/java/opennlp/embeddings/StaticEmbeddingModelConcurrencyTest.java: The ~30-line safetensors fixture writer is copy-pasted five times across the tests and the JMH benchmark. A small package-private test utility (e.g. SafetensorsTestFiles.write(dir, name, rows...)) would remove the duplication.
  • opennlp-embeddings/src/test/java/opennlp/embeddings/StaticEmbeddingModelConcurrencyTest.java: The class javadoc claims the test "Mirrors the LexiconConcurrencyTest pattern from the opennlp-wordnet module", but neither that test nor an opennlp-wordnet module exists in this repository. Correct or remove the reference.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/SafetensorsFile.java: The readFloat32 branch rejecting an element-count/byte-range mismatch has no test, unlike every neighboring error branch. One small negative test (e.g. shape [2] with data_offsets [0,4]) would pin it.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/FlatJsonFields.java (and the rest of the module): else and catch are placed on their own line after the closing brace, diverging from the } else { / } catch (...) { style used throughout the existing codebase. Checkstyle does not enforce this, but please align with the project style.
  • opennlp-embeddings/src/main/java/opennlp/embeddings/StaticEmbeddingModel.java: The pooling logic is documented as "verified against" MinishLab's MIT-licensed model2vec-rs. Please confirm no code was translated or ported from those sources; a derived port would require an MIT attribution entry in the distribution LICENSE file.

Human review will follow.

@krickert

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in 9dd6ff7, one pushback on the marker question:

Blocking:

  • TensorInfo: the shape is now copied on construction and on access, equals/hashCode compare by value, and elementCount() rejects overflow from crafted headers via exact multiplication. Tests pin the defensive copies, the value semantics, and the overflow.
  • Experimental marker: not applied, deliberately. The public surface is five types with a small, verified contract: the pooling formula is pinned against the reference implementations, the safetensors reader implements a frozen file format, and the listed follow-ups are additive consumers (an ANN index slots behind mostSimilar, the gRPC provider is a caller), not surface reshapes. WordVectorTable's marker reflected an unfinished feature; this one is finished. If the release manager prefers the annotation anyway it is a two-line change.
  • Distribution wiring: opennlp-embeddings added to the distr dependencies and its apidocs fileSet to the assembly, matching the other extension modules.
  • WordPieceVocabulary: dedicated test class added covering the line-number ids, duplicate rejection (naming both lines), the -1 sentinel, and token(int) bounds, which now fail loud with IllegalArgumentException instead of leaking an IndexOutOfBoundsException.

Minor:

  • Unicode escapes now require four hex digits; signed input fails loud. Tested.
  • skipValue holds numbers in skipped fields to the JSON grammar; malformed tokens fail loud, well-formed scientific notation still passes. Tested both ways.
  • elementCount overflow: fixed with the TensorInfo change above.
  • Checked IOException: agreed, the load and read entry points now declare IOException like the rest of the project's loaders; UncheckedIOException is gone from the module.
  • Boolean flags: replaced with Casing (UNCASED/CASED) and Normalization (L2/NONE) enums, so the switches cannot be swapped silently. Manual, README, tests, and benchmark updated.
  • The redundant punctuation pass lives in the reused opennlp-api tokenizers, so the fix belongs there; noted as a follow-up rather than worked around here.
  • Fixture writer: extracted to a shared test utility used by the tests; the JMH benchmark keeps its own copy because it compiles in a separate source set.
  • Stale javadoc reference removed.
  • The element-count versus byte-range guard has its negative test.
  • else/catch placement aligned with project style across the module.
  • model2vec-rs: no code was translated or ported. The implementation was written against the safetensors format specification and the documented Model2Vec pooling semantics; the Rust and Python implementations were used as behavioral oracles (outputs compared for numerical parity), not as source. No attribution entry is required.

@krickert krickert self-assigned this Jul 10, 2026
@krickert
krickert marked this pull request as draft July 11, 2026 13:22
@krickert

Copy link
Copy Markdown
Contributor Author

Moved this to draft - interfacing to match Onnx impl. Onnx's impl is better for accuracy, this one is for speed. Update incoming.

@krickert

Copy link
Copy Markdown
Contributor Author

Before this leaves draft: re-verify the two embed() throughput rows in the README performance table against the original JMH runs. The surrounding prose was reworked, but the numbers were deliberately left untouched pending that check.

@krickert
krickert deleted the branch apache:sentencepiece July 16, 2026 11:01
@krickert krickert closed this Jul 16, 2026
@krickert krickert reopened this Jul 16, 2026
krickert added 29 commits July 31, 2026 06:37
…ighbor scan, thread-safety hardening

Two analogy() bugs fixed. Passing equal terms crashed with
IllegalArgumentException("duplicate element") from Set.of; and the
exclusion compared raw input strings against vocabulary tokens, so on
an uncased model analogy("Man", "King", "Woman", k) handed "king"
straight back as a result. Exclusion now folds the terms through the
model's own tokenizer and excludes the resulting vocabulary rows,
which makes it case- and accent-consistent with embed() and tolerant
of equal or multiword terms. Both are pinned by new tests.

Nearest-neighbor scan reworked around three observations: per-row L2
norms are constants of the model, so they are precomputed at load
instead of recomputed (with a sqrt) for every row on every query; the
top-K selection now uses a bounded min-heap over primitive parallel
arrays instead of materializing and fully sorting one record per
vocabulary row per query; and the special-token check is a
precomputed boolean mask instead of per-row string hashing. The dot
loop uses four accumulators because the JIT must not reorder
floating-point additions and so cannot unroll the reduction itself.
The zero-norm-row NaN guard is preserved and now has its own test.
embed() drops an OptionalInt allocation per token (primitive -1
sentinel) and hoists the weight branch out of the accumulation loop.

Forked JMH, same configuration and fixture as the recorded baseline:
  embed:            999,222 -> 1,041,654 ops/s (+4.2%)
  mostSimilarTop10:   3,292 ->     9,173 ops/s (2.79x)

Thread safety reviewed and hardened: @threadsafe on
StaticEmbeddingModel, SafetensorsFile, and WordPieceVocabulary; the
class javadoc now documents why the one piece of global mutable state
in the tokenizer chain (WhitespaceTokenizer.INSTANCE's keepNewLines
flag) cannot affect results, since BERT basic tokenization replaces
all whitespace with plain spaces before that split runs; and a new
concurrency test runs 8 threads against one shared instance comparing
every result to the single-threaded reference.
Replaces the whole-file byte[] (capped at 2 GB by Java's int-indexed
arrays, and failing as an opaque OutOfMemoryError beyond it) with
positional FileChannel reads: the header is read eagerly, tensor data
streams straight into the caller's float[] through a reused 1 MB chunk.
File size is now unlimited; the remaining ceiling is per decoded tensor
(a float[] holds at most ~2.1 billion elements) and is checked with a
clear message. Peak load memory drops since file bytes and the decoded
array no longer coexist. A file truncated between read() and
readFloat32() fails loud instead of returning partial data.
…rbage

Writing the direct tests surfaced one gap: parseTop stopped at the
closing brace and silently ignored anything after it. Trailing
whitespace stays legal (writers space-pad the header to align the data
section), any other trailing content now fails loud.
… the model configs

Reads normalize from config.json and do_lower_case from
tokenizer_config.json (field names verified against published
Model2Vec-family releases), so callers no longer have to know a model's
switches to load it. The JSON scanning primitives move from
SafetensorsHeaderParser into a shared JsonCursor; FlatJsonFields reads
just the top-level booleans and skips everything else structurally.

A strip_accents explicitly set against do_lower_case is rejected rather
than silently mis-tokenized: the single lower-case switch follows the
BERT convention of stripping accents exactly when lower-casing, and the
error points at the explicit overload for deliberate choices.
Covers when to use a static table over a contextual model, the
one-call model-directory load and the explicit overload, thread
safety, the no-bundled-model license posture, and the safetensors
reader's guarantees.
…d load switches, distribution wiring

TensorInfo copies its shape on construction and access and compares by
value, so callers cannot corrupt validated metadata and equality behaves
like exported API should; elementCount() detects overflow from crafted
headers. The load and read entry points now declare IOException like the
rest of the project's resource loaders instead of wrapping in
UncheckedIOException. The two adjacent boolean load flags became the
Casing and Normalization enums, so the switches cannot be swapped
silently; the directory-based load maps the model's configuration onto
them and the manual, README, tests, and benchmark use the new signature.

The binary distribution now ships the module: opennlp-embeddings added to
the distr dependencies and its apidocs to the assembly, matching the
other extension modules.

JSON parsing tightened to the fail-loud contract: unicode escapes must be
four hex digits (Integer.parseInt also accepted signed input) and numbers
in skipped fields are held to the JSON grammar. WordPieceVocabulary gains
its own test class covering the line-number ids, the duplicate rejection,
the -1 sentinel, and reverse-lookup bounds, which now fail loud instead
of leaking an index exception. The safetensors fixture writer that was
copied across the tests is now a shared test utility, the stale
cross-module javadoc reference is gone, the element-count versus
byte-range guard has its negative test, and else/catch placement matches
the project style.
…ement

The distribution dependency added for the review round needs the managed
version like the other extension modules; without it the distr module
fails to resolve the artifact.
Matches the casing of the existing public WordpieceTokenizer.
TextEmbedder in opennlp-api is the text-level embedding contract:
embed(CharSequence), embedAll (default loops one at a time; runtimes that
batch efficiently should override), and dimension(). It is the text-level
counterpart of the word-level WordVectorTable, and the javadoc states the
layer difference.

StaticEmbeddingModel implements it with a thin CharSequence overload; the
existing embed(String) hot path is untouched. SentenceVectorsDL implements
it as the contextual tier: getVectors stays the primary entry point
unchanged, embed adapts it (OrtException wrapped unchecked, the seam is
runtime-neutral), and dimension() reads the model's declared output
metadata with a one-time probe fallback for dynamic shapes. A batched
embedAll override remains open as a follow-up.

The adapter test drives a real ONNX session: a committed 373-byte model
(generation script alongside) computes token_id times a fixed weight row,
so every expected vector is hand-computable. The gitignored *.onnx pattern
is force-added for this one bundled fixture; rat-excludes covers the
binary.
WordpiecePipeline is a module-internal copy of the five-stage BERT
pipeline over WordpieceTokenizer, pinned by the reference token
sequences. StaticEmbeddingModel uses it instead of a shared class, so
the hot path is unchanged operation for operation and the module no
longer depends on any tokenizer being reworked elsewhere. The
SentenceVectorsDL class javadoc is reduced to the factual contract.
SentenceVectorsDLEmbedderTest resolved the ONNX model with new File(url.toURI()),
which only works when the resource sits on the filesystem. When the test runs from
the opennlp-dl test-jar (as it does in opennlp-dl-gpu) the resource URI points inside
a jar and is not hierarchical, so new File(uri) threw IllegalArgumentException and
failed the opennlp-dl-gpu build. Copy the model out of the classpath into the JUnit
temp dir, matching the existing pattern in LoadVocabTest.
…er seam

Static embedding tables distilled from SentencePiece teachers (bge-m3 and the
XLM-RoBERTa family) now load and embed, multilingual vectors included.

The module's own WordPiece pipeline is replaced by the SubwordTokenizer seam:
WordpieceEncoder from opennlp-api for WordPiece models, the pure-JVM
SentencePieceTokenizer from opennlp-subword for SentencePiece models. Matrix
rows are resolved by piece string, never by tokenizer id, because the trained
.model file and the matrix vocabulary routinely order and offset ids
differently; a load-time sweep verifies every poolable piece has a row, so a
wrong file pairing fails loud at load, not at query time. The SentencePiece
row order comes from the Unigram model.vocab list of tokenizer.json (with
added_tokens overlaid), read by a purpose-built parser in the package's
existing JsonCursor style. The directory loader detects the tokenizer family
from the files present.

Verified for exact output parity against the reference implementation on a
real distilled multilingual model, including CJK input.
Applies the review conventions from the OPENNLP-1869 review to the embeddings module: private constructors and helpers gain javadoc, the TensorInfo record and FlatJsonFields validate their arguments at the boundary, and the hot-path and test-history commentary shrinks to what the code does.
…ories

The benchmark took only a synthetic fixture. A modelDir parameter now selects the table: the default 'synthetic' keeps the offline fixture, and passing model directories (-p modelDir=dirA,dirB) benchmarks real tables and reports one row each, which is how the potion-vs-bge-m3 comparison is produced.
…k numbers

TRAINING.md walks through distilling a table from a sentence-transformer teacher, with the multilingual bge-m3 SentencePiece model as the worked example: dimension guidance, assembling the model directory, loading and verifying parity in the JVM, and the WordPiece variant. The README performance section carries real potion-vs-bge-m3 throughput split into the embed and nearest-neighbor cost drivers.
Model2Vec mean-pools content pieces and never frames, so it removes [CLS]/[SEP] from the distilled vocabulary, keeping only [PAD]/[UNK]. Such tables could not load because the WordPiece encoder requires the frame tokens. The loader now caches the absent frame tokens onto the unknown row: the encoder still frames, and pooling skips the frame by id exactly as before, so which pieces are pooled does not change. [PAD] and [MASK] join the neighbor-exclusion set, since a distilled table keeps rows for them that text never tokenizes to.
…led directories

A Model2Vec distillation writes model.safetensors, tokenizer.json, and config.json, but not the vocab.txt and tokenizer_config.json a WordPiece model needs, nor the trained SentencePiece .model file. AssembleModel completes a WordPiece directory by deriving the two missing files from tokenizer.json (the vocabulary in id order, the casing from the normalizer's lowercase flag) and reports the SentencePiece .model file it cannot fabricate, then loads the result to verify it. Wired as its own opennlp-embeddings command with a launcher script and distribution entry, mirroring the spellcheck module.
scripts/distill_bge_m3.py is the runnable form of the TRAINING.md worked example. scripts/parity holds the reproducible comparison against the model2vec Python reference: the same model and the same multilingual sentences on both sides, the two vector sets checked against each other, and both single-thread throughputs measured with the same fixed-duration methodology. A run passes only when the vectors agree within float tolerance, so the two speeds it prints are for implementations producing the same answer.
Add StaticEmbeddingUsageExampleTest asserting the load-and-query workflow and
point the embeddings manual section at it.
Distillation, so producing a table no longer needs a Python environment:

- Add ModelDistiller, reproducing Model2Vec in Java: clean the teacher's vocabulary,
  run every surviving token through the teacher's ONNX graph as [bos, token, eos] and
  mean-pool the last hidden states, project onto the top principal components, then
  scale each row by its Zipf weight sif / (sif + p).
- Add OnnxTeacherEncoder for that forward pass, feeding the graph exactly the inputs it
  declares (input_ids, attention_mask, plus a zero token_type_ids for the BERT-family
  graphs that ask for one), and declare the onnxruntime dependency in the module at the
  root-managed version, the same engine opennlp-dl already runs.
- Add TeacherTokenizer, which decides which vocabulary rows survive into the table and
  rewrites tokenizer.json to describe it, copying every field it does not change byte
  for byte so the result stays a faithful fast-tokenizer description.
- Add RandomizedPca (Halko, Martinsson, Tropp), because a dense SVD of a 250k-row
  multilingual matrix is not practical in pure Java; component signs are fixed the way
  scikit-learn's svd_flip fixes them, so two runs are comparable rather than mirrored.
- Add SafetensorsWriter, the write side of the format SafetensorsFile reads, streaming
  the matrix in chunks so its overhead beyond the caller's array is constant.
- Add HuggingFaceModelCache so -teacher accepts a hub id and its files download once
  into a local cache; an optional file the repository does not have (a WordPiece
  teacher has no SentencePiece model) is reported absent, not an error.
- Register the DistillModel tool in the module CLI. It ends by assembling and verifying
  its own output directory, so a run that prints a summary is a directory that loads.

Documentation:

- Rewrite TRAINING.md around the DistillModel command instead of the Python uv and
  model2vec setup, keep the Python flow only as the parity reference, and record that
  two tables distilled independently from one teacher agree on their pairwise geometry
  but not axis by axis.
- Point the README "Getting a model" section at DistillModel as an alternative to
  downloading an already released table.

Folded duplication:

- Move firstRegularFile into ModelFileNames, next to the name lists it scans;
  StaticEmbeddingModel and ModelAssembler each carried a copy.
- Fold the boolean and string readers of FlatJsonFields onto one top-level walker with
  a ValueReader seam, so the object grammar, the duplicate-field check, and the
  trailing-content check exist once.
- Collapse the two identical file checks in EmbeddingVocabulary into requireRegularFile.
- Reduce SentenceVectorsDL.declaredOutputDimension to the single first-output read the
  loop actually performed, since every path returned on the first output anyway.

Javadoc and commentary:

- Document JsonCursor.consumeLiteral, requireEnd, and the new position(), and the two
  writer overloads in SafetensorsTestFiles.
- Fix TensorInfo's stale link to SafetensorsFile.readFloat32, which is readFloats now,
  and move its element-count description into the {@return} form.
- Describe Neighbor.token as one subword piece of the model's tokenizer rather than a
  WordPiece, now that SentencePiece tables load too.
- State on TextEmbedder that thread safety is implementation specific, which is what
  the two implementations actually promise, and mark up null in its tags.
- Drop the commentary that narrates history rather than behavior: the benchmark's
  design-doc justification, the "before the fix" and "used to crash" notes in the
  similarity tests, and the "untouched by the interface adoption" note in the DL test.

Tests:

- Add EmbeddingTestFixtures holding the king/queen/man/woman analogy table and the JSON
  string quoter, and point the similarity, concurrency, SentencePiece, usage-example,
  and assembler tests at it instead of their own copies of each.
- Build the size-mismatch and zero-vector fixtures with SafetensorsTestFiles rather
  than hand-rolling the header and the little-endian payload in the test.
- Pin FlatJsonFields.topLevelString: escapes, absent versus explicit null, nested names
  not matching, non-string values, duplicates, and null arguments.
- Add ModelDistillerTest, RandomizedPcaTest, and TeacherTokenizerTest over the
  distiller's pure pieces, and drop a duplicate IOException import in
  FlatJsonFieldsTest.

Build:

- Sort opennlp-spellcheck before opennlp-subword in the extensions module list.
…urface

A review of the model distiller, which had not been reviewed before, found
four defects that produce a wrong result rather than an error, and a set of
classes with no tests at all.

Correctness:

- RandomizedPca floored the CholeskyQR jitter at an absolute value, so the
  decomposition was not scale invariant. Scaling the input down by 1e-6 turned
  an exact rank-6 recovery into an explained variance of 0.0017 with pairwise
  dot products wrong by three orders of magnitude, while still returning a
  plausible table. The jitter is now relative to the Gram trace.
- RandomizedPca returned NaN instead of failing when the centered matrix was
  exactly zero, and a single non-finite input value poisoned its column mean
  and turned the whole result NaN. Both are now rejected.
- The distiller guard named nanToZero tested only Float.isNaN, so an infinity
  from the teacher passed through into the PCA. Renamed to nonFiniteToZero and
  switched to Float.isFinite, with the divergence from numpy nan_to_num noted.
- The PCA-skip branch kept the un-reduced matrix but left the requested
  dimension as the row stride, so the Zipf loop scaled the wrong cells and the
  writer rejected the array. Reachable with any vocabulary smaller than
  pcaDims, for example 100 tokens at the default 256.

Tests, all previously absent:

- SafetensorsWriter had no test file and its one case wrote six floats, so the
  1 MiB streaming loop never crossed a chunk boundary. Added a 400x1024 round
  trip along with the header layout, alignment and reject paths.
- ModelDistillerTest never called distill, leaving every argument check
  unverified. It now covers all five reject paths.
- Added first tests for ModelFileNames, HuggingFaceModelCache,
  OnnxTeacherEncoder and the module CLI.
- RandomizedPcaTest pins scale invariance, the non-finite rejection and
  determinism across pool sizes.

Also corrected the TRAINING.md WordPiece section, which told the reader to run
AssembleModel on output the distiller has already assembled.

Module tests go from 156 to 273, none skipped.
The distiller fetched teacher weights from a moving ref and executed the ONNX
graph without checking anything. DownloadUtil has always refused a model whose
sha512 sidecar it cannot read, so this brings the hub path to the same posture.

- Resolve the ref to a commit once, then request every file at that sha, so a
  force-push midway cannot mix two revisions into one cache directory. A
  teacher may now name a revision as org/model@revision.
- Verify every file against the digest the hub publishes in x-linked-etag,
  choosing the algorithm by hex length: 40 is the git blob SHA-1 over
  "blob <len>\0" and the bytes, 64 is the SHA-256 of the content. The digest is
  computed over what was written to disk. A mismatch deletes the partial file
  and throws, in DownloadUtil's wording.
- Refuse a file whose digest the hub does not publish, rather than accept
  something unverifiable. This is the point of the change.
- Record the resolved commit in .opennlp-revision, which also marks the
  directory as a complete snapshot, and carry it into the distilled model's
  config.json as teacher_revision so a table can name the teacher it came from.
- Make the cache directory name injective. It replaced '/', '.' and '@' without
  distinguishing them, so acme/model@v1, acme/model.v1 and acme/model_v1 shared
  one directory, and the cached path answers without contacting the hub, which
  would have served one teacher another's files.

Headers are read by walking HttpResponse.previousResponse(): with Redirect.NORMAL
the final CDN response carries neither header, while the redirecting hub response
carries both. A test fails if that walk is removed.

Tests need no network. A loopback HttpServer serves canned replies and records
the requests made, covering both digest forms, a corrupted body, a missing and
six malformed etags, optional and required 404s, the redirect path, the zero
request cached path, revision pinning and the directory collision.

Module tests go from 273 to 310, none skipped.
@krickert
krickert force-pushed the static-embeddings branch from 558ebd3 to ce7090c Compare July 31, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants