Skip to content

Encoder path, quantized router index, and a catalog SPI - #111

Merged
bsbodden merged 28 commits into
mainfrom
feat/router-classifier
Aug 8, 2026
Merged

Encoder path, quantized router index, and a catalog SPI#111
bsbodden merged 28 commits into
mainfrom
feat/router-classifier

Conversation

@bsbodden

@bsbodden bsbodden commented Aug 8, 2026

Copy link
Copy Markdown
Member

Everything needed to ship the pretrained router classifier, plus the pieces that turned out to be
prerequisites.

EmbeddingGemma runs on our own runtime

EncoderForwardPass — a separate pass, not a flag on the decoder. A causal model walks tokens one
at a time because position p only needs keys it has already visited; an encoder needs every
position's key at the same layer, so layer L cannot start until L−1 has finished the whole sequence.
Inverted loop nesting, no KV cache, matching llama.cpp.

Read out of llama.cpp at the pinned oracle rather than inferred. LLAMA_SWA_TYPE_SYMMETRIC masks
when |p1 − p0| > n_swa/2, so the window is centred on the query and reaches 256 either side — the
causal reading of the same n_swa=512 would look back twice as far.

Each guard checked by breaking it:

run min cosine
8 committed probes 0.99956 (floor 0.999)
940 tokens, ad hoc 0.99939
940 tokens, window disabled 0.86032
8 probes, forced causal 0.57266

That last row is why LlamaForwardPass now rejects a bidirectional config: encoder weights through
the causal pass return a plausible unit vector, not an error.

Positions within a layer are independent, so the pass parallelises: 1522 s → 300 s for a full
index build, producing a quantized.bin with the same SHA-256 as the sequential build.

The index ships at 0.65 MB

Requires vectors 0.1.7, which adds quantizedOnly — before it, a quantizer added a compressed
copy beside the full-precision vectors and made the artifact bigger.

storage accuracy zipped
float32 0.9083 5.13 MB
SQ4 quantized-only 0.9019 0.64 MB

Matryoshka truncation to 256 dims was measured and rejected: larger (1.72 MB), less accurate
(0.8891), and it took nearly all its loss out of extraction alone.

Corpus moved out, and a shape gap closed

Prompts now live in model-router-corpus so a
licence question about one benchmark cannot block a release. The index pins the corpus digest.

Every farmed summarization prompt carried an XSum passage, so a request sent without a document
embedded nowhere near them. Fifteen ordinary instruction-only queries classified as tool-use, chat,
creative, sql and code — none correctly. After 60 instruction-only prompts, all fifteen are,
including two that appear nowhere in the corpus. Held-out accuracy barely moves (0.9019 → 0.9044)
because the eval split hardly sampled the broken region, which is how a 90% classifier misrouted
every bare instruction it saw.

Models register themselves

ModelCatalogProvider in models-api — ModelJars depends on models, so an SPI it implements cannot
live beside the router without inverting the tiers.

ModelRouter.discoverLocal()       // installed catalogs
ModelRouter.discoverLocal(true)   // plus on-device intelligence

Apple Intelligence is opt-in via a general requiresOptIn() flag: it is present because of the
hardware, so discovering it by default would make identical code route differently on a Mac than in
production.

Unprofiled models are estimated, not dropped. Local generation is memory-bandwidth bound, so
tokensPerSecond × sizeBytes is roughly constant on one machine — any measured peer calibrates the
rest. Falls back to pessimistic constants when nothing has been measured, and never overwrites a
real measurement.

Reviewer notes

  • AppleFoundationModelsCatalog has only run on Linux, where it correctly returns empty. Its
    measurement path has never executed on Apple silicon — worth a look from someone with a Mac.
  • The equivalence gate cannot cover the sliding window: its longest probe is ~25 tokens and the
    window only masks past position 256. EncoderAttentionWindowTest pins the arithmetic instead;
    end-to-end coverage is feat(purejava): enable Q5_K batched prefill #25.

bsbodden added 28 commits August 7, 2026 10:00
Replaces hand-authored exemplars with prompts drawn from public benchmarks:
MBPP, GSM8K, gretel text-to-sql, ARC-Challenge and BIG-Bench Hard, dolly-15k,
XSum, OPUS-100, Hermes function-calling and OASST1. 2391 prompts over 10 tasks,
80/20 train/eval, with per-source provenance and licence in sources.json.

Hand-authored prompts describe how one person phrases a task. They are
internally consistent and separate cleanly, which makes a classifier trained on
them look better than it is; real prompts carry the phrasing variety the
classifier actually has to survive.

The corpus is not shipped in the jar. Only the input side of each benchmark is
taken and reference answers are discarded. Summarization and translation have no
benchmark of user-style requests, so those rows are documents or sentence pairs
wrapped in an instruction, recorded as wrapped: true.

TaskExemplars becomes a reader for this format rather than a loader for a
bundled resource, so callers can classify against their own tasks by supplying a
corpus in the same shape.
…lection

Embedding a few thousand prompts takes long enough that doing it per process
would dominate the cost of routing, and the result is identical every time, so
it belongs in the artifact rather than the application's boot path.

Vectors are only comparable within the model that produced them, so the builder
writes a manifest recording the embedding model id and dimension beside the
index. An index searched with the wrong model returns confident nonsense rather
than an error, which is the failure the manifest exists to prevent.

Flat scan rather than an approximate index: a few thousand vectors search
exactly in well under a millisecond, and an approximate index would make
classification non-deterministic for no gain at this size.

The embedder is a one-method interface so models-router takes no dependency on
an embedding backend; the build wires in whichever ModelJar the index is pinned
to.
A query takes the label of its single nearest indexed prompt. Nearest-neighbour
rather than a per-task centroid because a task is not one region of embedding
space: 'write a Dockerfile' and 'fix this segfault' are both code and sit far
apart, and their midpoint is neither.

A query farther than the threshold classifies as null, and ModelRouter then
routes on cost, latency and reliability with quality averaged across tasks.
Guessing a task for an unfamiliar request would silently apply the wrong
quality column.

TaskIndex reports plain cosine rather than the collection's score.
SimilarityFunction.COSINE scores as (1 + cosine) / 2 over [0, 1], so on that
scale an unrelated query sits near 0.5 and a threshold picked as though it were
cosine would admit almost everything. Converting at the boundary keeps the
threshold on the same scale as the measurements it is derived from.

A query whose width does not match the index is an error rather than a miss:
width is the only mechanically detectable form of 'wrong embedding model', and
the wrong model of the same width would otherwise return confident nonsense.
Build and evaluate live in one command because both must use the same embedding
model; an index and an accuracy figure produced by different models say nothing
about each other.

evaluate exits non-zero below --min-accuracy, which is how CI gates a corpus or
model change. It also prints per-task accuracy and the first misses, since an
aggregate that drops usually drops in one task and the label tells you which.
Each model takes minutes to embed the corpus, so a run interrupted partway
previously reported nothing at all.
The first bake-off put chat at 26% and summarization at 36% while code was at
100%. The cause was the corpus, not the models.

Dolly's summarization and information_extraction rows carry their text in a
separate context field — 100% of them — and taking the instruction alone leaves
'What is a dispersive prism?' labelled summarization. Extraction now appends the
passage, since extraction is defined by having something to extract from.
Summarization now comes from XSum wrapped in an explicit instruction, dropping
dolly's category entirely: its instructions are question-answering over a
context, which is a different task.

Dolly's creative_writing also holds open-ended musings and even arithmetic word
problems, so it is filtered to rows that ask for text to be written.

OpenAssistant conversation roots are dropped for chat. They are arbitrary user
requests spanning every task in this taxonomy — 'Make a story about Rick and
morty', 'Go through my emails and provide a status update' — so labelling them
chat taught the classifier that any request is chat. Chat now comes from dolly's
open_qa, general_qa and brainstorming rows, which are genuinely open-ended
questions carrying no context.

Reasoning now spans six BIG-Bench Hard configs rather than boolean_expressions
alone, which was a narrow synthetic slice.
The first bake-off on the corrected corpus showed the nearest wrong training
neighbour sitting at distance 0.267 while the median correct one sits at 0.430.
The two distributions overlap, so no threshold can separate right answers from
wrong ones — any cut that removed mistakes would remove far more correct
classifications.

What a threshold can do is decline queries no task covers, so an out-of-domain
request routes on cost and latency instead of being forced into whichever task
happens to be least distant. This measures both distributions against the same
index and sweeps candidate thresholds, so the shipped value is read off the
trade rather than picked.
The index is a directory of memory-mapped files and a jar entry cannot be
mapped, so it travels as a zip and is expanded once into a cache directory.

The cache path carries a digest of the archive, so a jar upgrade that changes
the index lands in a new directory instead of silently reusing the old one.
Expansion goes into a scratch directory that is then moved into place, so a
reader never observes a half-written index and two processes racing cannot
interleave their writes.

Entries whose resolved path escapes the destination are rejected: a crafted
archive could otherwise write anywhere the process can reach.
…nifest

Map.copyOf and the other immutable collections randomise iteration order per
JVM run — three runs of the same input gave three different orders. The index
derives its document ids from that order and the manifest now derives a corpus
digest from it, so both would have differed on every build from identical input.
The corpus is held in a LinkedHashMap wrapped as unmodifiable instead.

The manifest records corpusSha256 so a build can tell an index was left behind
by a corpus edit. A prompt count alone would miss one prompt being swapped for
another, which is the edit most likely to be made by hand and forgotten.
Generated against the pinned oracle (6ea215d17) with mean pooling and L2
normalization, over the unchanged probe set. The artifact digest matches the
ModelJars catalog entry exactly.

The runtime cannot load this model yet — its GGUF declares
general.architecture=gemma-embedding, which PureJavaBackend rejects — so this
is groundwork rather than a qualification. Generating it now records the oracle
version that was current when the vectors were taken; deferring it would mean
regenerating against whatever llama.cpp happens to be checked out later.
TaskIndexCli returns an exit status rather than calling System.exit, matching
EmbeddingEquivalenceCli: its evaluate subcommand is a gate, so the verdict has
to travel out through the process status.
EmbeddingGemma is the strongest task classifier in the bake-off (90.8% vs the
next best), but nothing could run it: PureJavaBackend rejected the architecture
outright. It is an encoder, and the existing pass is causal throughout.

Bidirectional attention is not a mask swapped into the decoder loop. A causal
model walks tokens one at a time because position p only needs keys it has
already visited — that is what makes a KV cache work. Here every position needs
every other position's key at the same layer, so layer L cannot start until
L-1 has finished the whole sequence. That inverts the loop nesting, which is
why this is a separate pass rather than a flag on the existing one. llama.cpp
allocates no KV cache for these architectures for the same reason.

Read out of llama.cpp at the pinned oracle rather than inferred, because four
details would each have produced a model that runs and disagrees:

  - LLAMA_SWA_TYPE_SYMMETRIC masks when |p1 - p0| > n_swa/2, so the window is
    centred on the query and reaches 256 either side. The causal reading of the
    same n_swa=512 would look back twice as far.
  - the projection head runs after pooling, not per position
  - f_attention_scale is 1/sqrt(n_embd_head_k), which happens to match what the
    existing code already computes
  - the SWA period is 6 with no metadata key to read it from

The dense head travels with the model: EmbeddingGemma's published vectors are
its output, so skipping it gives a vector of the right width pointing somewhere
else. Loaded as a unit — a half-present head is rejected rather than partly
applied — and pooling likewise moves under the model's control, with an explicit
pooling() call now refused rather than silently ignored.

Verified against llama.cpp 6ea215d17, and each guard checked by breaking it:

  8 committed probes          min cosine 0.99956  (floor 0.999)
  940 tokens, ad hoc                     0.99939
  940 tokens, window disabled            0.86032
  8 probes, forced causal                0.57266

That last figure is the reason for the LlamaForwardPass guard rejecting a
bidirectional config: causal weights run through the wrong pass return a
plausible unit vector, not an error.

The committed gate cannot cover the window — its longest probe is ~25 tokens
and the window only masks past position 256, so every probe would pass
unchanged with it removed. EncoderAttentionWindowTest pins the arithmetic
instead, and records why the gate is blind to it.
TaskIndexCli passed --pooling unconditionally, which an encoder now rejects:
it reads its pooling from its own metadata and refuses to be told otherwise.
Applies the flag only where the caller genuinely has to supply it.
EmbeddingGemma is trained with Matryoshka Representation Learning, so each
prefix of its output is itself a usable embedding. Truncating shrinks the
router index proportionally, which is worth measuring before deciding how the
index ships.

Named matryoshkaDimensions rather than dimensions on purpose. No GGUF metadata
records whether a model was trained this way, so nothing can check it, and
truncating a model that was not discards trained dimensions and degrades
retrieval without failing. A caller who does not know the word will not reach
for the method by accident.

Truncation precedes normalization: a prefix of a unit vector is shorter than
one, and cosine over an unrescaled prefix is not the similarity the model was
trained to produce.

Measured on the router corpus, 1881 prompts, 469 held out:

  768   0.9083   5.13 MB zipped
  256   0.8891   1.72 MB
  128   0.8486   0.87 MB

The aggregate understates what happens. Going 768 -> 256 loses nine prompts
in total and eight of them are extraction, which falls 0.720 -> 0.560 while
the other nine tasks stay flat or improve. Extraction prompts are the long
context-bearing ones, and they are the ones that need the fine dimensions to
separate from chat.
Two things needed to compare storage choices for the shipped router index.

The quantizer is now written to the manifest and restored by TaskIndex.open.
VectorCollectionBuilder defaults to NONE, so opening a quantized index without
this would have handed the reader a config that disagrees with the bytes on
disk. Indexes written before the key existed read as NONE, which is what they
were.

The embedding cache is the difference between a comparison that gets run and
one that does not. Embedding is 25 minutes of a 25-minute build and everything
after it takes milliseconds, so five storage variants would have meant two
hours of recomputing identical vectors. Keyed by prompt text and scoped by
model and width: a cache reused across models would answer confidently with
another model's vectors, so a mismatched identity discards rather than mixes.

Measured with them, 1881 prompts at 768 dims, total zipped and the quantized
payload alone:

  NONE   0.9083   5.13 MB   (vectors.bin 5.51 MB raw)
  SQ8    0.9083   6.28 MB   quantized.bin 1.38 MB
  FP16   0.9083   7.67 MB   quantized.bin 2.76 MB
  SQ4    0.9083   5.75 MB   quantized.bin 0.70 MB
  BQ     0.9190   5.33 MB   quantized.bin 0.20 MB

Quantization adds a compressed copy beside the full-precision vectors rather
than replacing them, so it makes the artifact bigger. Accuracy is identical
across NONE/SQ8/FP16/SQ4 because FLAT rescores against full precision — the
quantized copy never reaches the answer. See #28.
vectors 0.1.7 adds quantizedOnly, which drops the full-precision copy instead
of writing compressed codes beside it. Until now a quantizer made this index
bigger; now it makes it eight times smaller.

Rebuilt with SQ4 against the same 1881 prompts:

              accuracy   zipped
  float32     0.9083     5.13 MB
  SQ4         0.9019     0.64 MB

vectors.bin is zero bytes and quantized.bin is the index. 0.9019 matches the
standalone measurement of 4-bit codes exactly, so the read path in vectors
agrees with scoring them directly.

Per task the loss is diffuse rather than concentrated: reasoning 0.810 ->
0.762 and chat 0.840 -> 0.820, everything else unchanged including extraction
at 0.720. That is the opposite of Matryoshka truncation to 256 dims, which was
larger (1.72 MB), less accurate (0.8891), and took nearly all of its loss out
of extraction alone.

Also bumps vectorsVersion 0.1.5 -> 0.1.7 and relocks. Two versions in one
step, so the whole build was checked rather than just models-router.
Three changes that finish the router index as a shippable artifact.

Encoder positions now run concurrently. Within a layer they are independent by
construction — each reads the whole sequence's keys and values and writes only
its own hidden state — so the only thing keeping them sequential was sharing
one set of scratch buffers. One bundle per worker fixes that without touching
any arithmetic. vectors' own parallel-matmul threshold never fired here: the
widest projection is under a million elements, so the whole encode ran on one
core however many were free.

  index build   1522 s -> 300 s   (5.1x)
  8-probe gate  1.8 s -> 0.9 s

Bit-exact, not just close: the parallel build produces a quantized.bin with
the same SHA-256 as the sequential one, and the equivalence gate still reports
min cosine 0.9995565 against llama.cpp. Strided rather than blocked work
assignment so a sliding window, whose cost varies with position, does not
leave one worker holding the expensive rows.

The 0.64 MB index is now a models-router resource, with tests that open the
real archive rather than a fixture — a resource is easy to ship broken because
nothing reads it until a user does. They pin the corpus digest, the prompt
count and the quantizer, so a rebuild against a different corpus or a silent
return to full precision both fail here.

TaskIndexResourceTest previously asserted that extractTo fails because no
index was packaged. That state no longer exists, so it now asserts the
packaged one expands.

MAX_TOTAL_BYTES drops from 512 MiB to 64 MiB, set against the real artifact
rather than guessed before one existed.
The prompts are third-party text under a mix of source licences — MBPP,
GSM8K, dolly, XSum, OPUS-100, ARC, BIG-Bench Hard, a Hermes function-calling
set. Vendoring them here means a licence question about any one benchmark
blocks a release of the whole library. They now live in
integrallis/model-router-corpus, and what ships here is the derived index:
embeddings, not prompts.

Not a size decision. The tracked corpus was 617 KB against an 11 MB .git; the
1.1 GB under corpus/ was always the gitignored HuggingFace download cache.

Nothing in the build depended on the corpus — the index is a committed
resource — so the only reference was TaskExemplarsTest, which already skipped
when the corpus was absent. It now looks for a sibling checkout and honours
-Dmodels.router.corpus, so it still runs for anyone who has one.

The index pins corpusSha256, so the two cannot drift apart unnoticed: an index
built from an edited corpus fails BundledTaskIndexTest rather than shipping.
The classifier had a shape gap, not a volume gap. Every farmed summarization
prompt carried an XSum passage and every extraction prompt a dolly context, so
a request sent without a document embedded nowhere near them. Fifteen ordinary
instruction-only queries — "Summarise this quarterly report in three bullet
points", "Extract the invoice number and total from this receipt" — classified
as tool-use, chat, creative, sql and code. None correctly.

integrallis/model-router-corpus adds 60 instruction-only prompts covering that
phrasing. All fifteen now classify correctly, including two that appear
nowhere in the corpus, so the fix generalises.

Held-out accuracy 0.9019 -> 0.9044, extraction 0.720 -> 0.741, reasoning
0.762 -> 0.786. The aggregate barely moves because the eval split hardly
sampled the region that was broken — which is how a 90% classifier could
misroute every bare instruction it was given.

Index 0.64 -> 0.65 MB. BundledTaskIndexTest repins the corpus digest and
prompt count, so index and corpus still cannot drift apart unnoticed.
Using the router began with writing out every model's price, latency and
per-task quality by hand. Nobody knows their model's time to first token, and
the figure is hardware-specific, so anything copied from a README is a
measurement of somebody else's machine — and the router would score on it with
complete confidence.

Every value already exists in the ModelJars catalog, selector-matched to the
current CPU, core count, JDK and vector width. Nothing was reading it.

    ModelRouter router = ModelRouter.discoverLocal()
        .policy(RoutingPolicy.BALANCED)
        .build();

ModelCatalogProvider lives in models-api because the dependency runs the other
way: ModelJars depends on models, so an SPI it implements cannot live beside
the router without inverting the tiers. DiscoveredModel is deliberately not a
routing type — a catalog reports what a model is, and something downstream
decides what that is worth.

Three failure modes it refuses to paper over. No catalog installed logs how to
add one and returns an empty fleet, because routing between hosted models only
is legitimate. A catalog that throws is skipped with a warning rather than
taking down discovery, so a second working catalog still contributes. And a
model with no performance profile for this hardware is skipped loudly rather
than defaulted: inventing a latency is exactly the failure this replaces.

Hosted pricing is the other half and is not here yet — see #30.
Apple Intelligence is present because of the hardware, not because anyone
installed it. Discovering it by default would make identical code route
differently on a developer's Mac than in production, and that difference
surfaces as unexplained behaviour rather than as a decision. So
discoverLocal() leaves it out and discoverLocal(true) includes it.

Implemented as an opt-in flag on the SPI rather than an Apple special case in
the router: requiresOptIn() defaults to false, and Apple is simply the first
provider to set it. Anything else environment-provided gets the same treatment
for free.

The provider measures rather than guesses. Apple publishes no throughput
figures and the framework exposes no counters, so the options were to measure,
invent, or report nothing and be silently skipped by CatalogDiscovery — which
would have made the opt-in a no-op that looked like it worked. It times one
short generation, once, behind the opt-in. Whole-call latency stands in for
time to first token because this path does not stream; that overstates TTFT,
biasing the router away from this model rather than towards it, which is the
safe direction for an estimate.

Availability is checked, not inferred from the platform: Apple Intelligence
can be switched off, unavailable in a region, or still downloading, and macOS
on Apple silicon is necessary but not sufficient.

Tests run everywhere. The common case is a machine with no on-device model at
all, where reporting nothing must be silent and cheap, and one test asserts
the services registration because a missing file there is invisible until a
user hits it.
Skipping was the wrong call. A model absent from the fleet cannot be chosen at
all, and the usual reason it has no profile is that it was installed recently,
not that it is unusable.

The estimate is calibrated rather than invented wherever it can be. Local
generation reads the whole weight file per token, so it is bounded by memory
bandwidth and tokensPerSecond x sizeBytes is roughly constant across models on
one machine. Any measured peer therefore fixes that constant for this
hardware, and a model's size predicts its rate from there — a property of how
inference works rather than a guess about a particular model. A measured 4 GB
model at 40 tok/s implies about 20 for an 8 GB one, which is what the test
pins.

DiscoveredModel gains sizeBytes for this. It is the strongest predictor
available and every catalog already knows it.

Median across measured peers, not mean: one model benchmarked while the
machine was busy would otherwise drag every estimate with it.

With nothing measured and no size known there is no signal left, and the
estimate falls back to deliberately pessimistic constants. Pessimism is the
safe direction — an estimate that flatters a model wins latency-sensitive
routing it has never demonstrated, while one that understates it merely loses
ties to models that have actually been measured.

Estimated models are named in a log line saying whether the estimate was
calibrated or a fallback. An estimate that scores exactly like a measurement
is the thing worth being able to find out about.

A measurement is never overwritten by an estimate, and a catalog reporting an
absurd size yields a valid low rate rather than a failed discovery.
0.3.0 is published and immutable, and this release carries the catalog SPI
that ModelJars needs to implement a provider — it cannot compile against
0.3.0, which predates com.integrallis.models.api.catalog.
A global gitignore carrying *.zip kept the index out of every commit that
added it. Local builds passed throughout because they read the file straight
off disk, tracked or not; CI is the first place the difference between "on
disk" and "in the repo" is visible, and it failed there immediately.

BundledTaskIndexTest did its job — opening the real archive rather than a
fixture is what turned an artifact that would have shipped empty into a failed
build. The local run was the misleading one.

Force-added, with a negation in models-router/.gitignore so the same global
rule cannot silently drop it again.
verifyArchitectureBoundaries forbids the ModelJars group coordinate anywhere in
models source, strings included — the task is written to avoid matching its own
literal, so that breadth is deliberate rather than an oversight. The install
hint carried the coordinate verbatim and tripped it.

The guard is right and the message was wrong. A caller who needs a catalog can
find the artifact from the project name; models spelling out its coordinate is
the beginning of exactly the coupling the check exists to prevent.

Also the reason my local runs missed this: verifyArchitectureBoundaries is not
wired into check, so it only ran in CI's compliance job.
verifyReleaseMetadata holds sixteen files to the same version, and bumping
gradle.properties alone left every one of them behind. It is not wired into
check, so this only failed in CI — the same reason the architecture guard did.

CHANGELOG gains a dated 0.3.1 section covering the encoder architecture, the
pretrained classifier and its shipped index, the catalog SPI, Apple on-device
discovery, Matryoshka truncation, the parallel encoder, and the vectors 0.1.7
bump.

Also updated: Antora component and attributes, docs package manifests and
lockfile, landing-page pill, native crate and its lockfile, notebook defaults,
and the published coordinates quoted across four READMEs. Antora's
vectors-version attribute moves to 0.1.7 as well, which was simply stale.
My version bump rewrote antora.yml's `version: 'current'` to '0.3.1'. That is
Antora's component version, not a display string: it decides the published
path, so the site moved from site/models/current/ to site/models/0.3.1/ and
verifyGeneratedSite — which looks under current/ — found none of the 1272
Javadoc attachments it expected.

Only display_version and the models-version attribute track the release.
@bsbodden
bsbodden merged commit 5e48646 into main Aug 8, 2026
31 checks passed
@bsbodden
bsbodden deleted the feat/router-classifier branch August 8, 2026 21:01
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.

1 participant