Skip to content

feat(embedding): encodeMultimodal for EmbeddingGemma 2, and a dispose that waits for work in flight - #63

Merged
lloyal-research merged 20 commits into
mainfrom
feature/video-ability
Oct 26, 2026
Merged

lloyal-research merged 20 commits into
mainfrom
feature/video-ability

Conversation

@lloyal-research

Copy link
Copy Markdown
Contributor

Summary

encodeMultimodal embeds images, text around them, audio and video windows on an embedding context, through liblloyal's segments rail; a disposed context frees its native state only once its work in flight completes.

Draft: the release this belongs to still adds prefill progress (an AsyncProgressWorker over liblloyal's per-input progress), and the submodule moves to liblloyal's merge commit once lloyal-ai/liblloyal#52 lands.

What changes

  • encodeMultimodal(prompt, inputs, audioLimits?) on an embedding context with a projector: one input, one pooled vector, read whole in one dispatch. Anything else is refused before any work.
  • The 768-wide vector reaches JavaScript for EmbeddingGemma 2, whose hidden width is 512.
  • dispose() refuses every later call at once, frees the native state when the last call in flight completes, and returns one promise, resolved then. Every worker a context queues holds it, and a context collected mid-flight drains the same way.
  • CI gets the EmbeddingGemma 2 step and the time its largest inputs need on a CPU runner.
  • liblloyal at the head of its branch for this work.

Verification

  • Every reference input through the addon against llama.cpp's server vectors: images, audio, mixed parts, task framings, languages, image budgets and a video window, at cosine 0.999 or better.
  • Scenario suites on long footage (two animals in a clip, a number plate on dash-cam footage, Hindi dialogue), read from media packs by digest. The footage itself is not in the repository.

…nst llama.cpp

EmbeddingGemma 2 joins the services matrix as a pinned, checksummed model and
projector pair, with its own suite (`npm run test:embedding`) and CI step. The
suite checks that the projector carries a vision and an audio encoder, that
getEmbeddingDimension() is the model's output width, and that each query in
liblloyal's reference fixture embeds within a cosine of 0.999 of the vector
llama.cpp's server gives.

Red on the addon built from liblloyal 3.1.1: the width read 512, the
backbone's, and so did every vector. Green on liblloyal's
feature/video-ability, which reads the output width. The submodule pin moves
to liblloyal 3.2.0 when it is tagged; until then CI has neither the width nor
the checksum manifest.

The check that a suite loaded this checkout's addon moves into
test/local-addon.ts, shared by the audio and embedding suites.

Judged by hickey 0.2.1:

  Gets in: The whole makes more sense with this change. EmbeddingGemma 2
  enters through the existing `services` matrix extension point, and the rule
  that a suite must prove it loaded this checkout's addon now has one owner
  (`test/local-addon.ts`) that both service suites compose. The one dependency
  to confirm is that the liblloyal submodule pin includes the new checksum
  manifest and reference fixture.
… a video window

An embedding context with a projector takes a prompt with media markers and
its typed media, as a branch prefill does, and embeds it whole:
EncodeMultimodalWorker marshals the input into liblloyal's MtmdSource with
the model's own special tokens and hands it to embedding::encode, which
places the stream in one dispatch and refuses, before anything runs, one
that does not fit. Read the vector with getEmbeddings().

The context remembers it was created with embeddings on, and
_cellsMultimodal frames an input on such a context the same way, so the
measure is exactly what that dispatch must hold.

Red on today's addon: there was no encodeMultimodal. Green on liblloyal's
feature/video-ability against its EmbeddingGemma 2 reference: each of the
four images, the text around an image and the clip within a cosine of 0.999
of llama.cpp's vector; each query finds its own image or clip, 5 of 5; a
context one entry short of the measure refuses the input and still embeds as
the reference does, and one of exactly the measure embeds it. Measuring
without the special tokens fails that case.

Guards, each in its own process with LLOYAL_LOCAL=1 on Metal: the text suite
with nomic (332 passed), test:images (52 passed), test:audio (7 passed).

The suite leaves the type gate with the audio suite, for the same reason:
the SDK that declares video and encodeMultimodal ships from hdk after this
addon.

Judged by hickey 0.2.1:

  Gets in: The whole makes more sense with this change: multimodal embedding
  is added as one more marshaller over liblloyal's `MtmdSource` and
  `embedding::encode`, and the special-token framing is decided once per
  context, so the measure and the encode stay consistent without either
  re-deciding the rule.
… runner

The clip and the infographic are each over a thousand cells, and on a CPU
each takes minutes to encode: llama.cpp's own server took about ten minutes
on the CPU for the whole reference. The step gets thirty minutes, as the
text suite's does.

Judged by hickey 0.2.1:

  Gets in: The whole makes more sense with this change: the embedding suite's
  time budget stays a single per-step decision next to the suite it governs,
  now sized to its real CPU cost and explained like the text suite's.
…d parts, task framings, languages, budgets, a window

The suite follows liblloyal's reference, where every entry is ordered parts
(text, or an image, the clip or a recording). One media table and inputOf turn
parts into encodeMultimodal's prompt and inputs; the shared setup moves into
test/embedding-fixtures.ts. The cases:

- every entry embeds where llama.cpp's server puts it, at a cosine of 0.999:
  text in each task framing and in Spanish and Japanese, images at the
  projector's own budget and at 70 and 1120 image tokens, the clip, the
  recording, and two mixed inputs;
- each query finds its own image, clip or recording, in any language;
- a window from the middle of the clip, at its own times, is what the clip's
  query finds over every still;
- a mixed input costs exactly its parts and the model's framing, by
  _cellsMultimodal;
- the image budget sets what an image costs;
- the measure is exactly what one dispatch admits (as before);
- a recording over its limits, and a prompt whose markers do not match its
  inputs, are refused, and the context then embeds as it did;
- encodeMultimodal is refused on a context without a projector or not made
  for embeddings.

Every short-lived context is freed when its use ends (withContext), and
embeds on the shared context run one after another, since each clears,
encodes and reads the same context.

These are guards: the addon needed no change for them. Green on Metal with
LLOYAL_LOCAL=1, 11 cases, on the build that also carries the next commit's
dispose fix. A budget outside the projector's trained range is not refused
here: llama.cpp takes a custom budget as given and exposes no range to check
against, so the bound belongs to rig's catalog row.

Judged by hickey 0.2.1:

  Gets in: The whole makes more sense with this change. Every entry is read
  as ordered parts through one table, and every context lives exactly as long
  as the scope that uses it. The next media kind is one table row, and the
  next short-lived context frees its GPU memory when its use ends.
…only once its work in flight completes

dispose() freed the projector, the context and the model at once, while a
worker of any async call could still be using them on the native pool, and
~SessionContext did the same if the object was collected mid-flight.

Every worker a context queues now derives from ContextWorker. Its
SessionContext::InFlight hold counts the work and keeps the JS object from
being collected until the worker is destroyed, after OnOK or OnError on the
JS thread. dispose() refuses every later call at once and frees the native
state when the last hold is released, or immediately if none is held. It
returns a Promise, resolved once the state is freed; every call returns the
same one. Teardown has one owner, freeNative(): the branch store drains while
the context is alive, then the projector, the context and the model. The
destructor now drains the store as dispose() always did.

Red first, in a child process (test/__dispose-in-flight-child.ts), because
the failure is a crash: an encodeMultimodal on the infographic and the clip,
disposed at once, ran on freed memory until the process's peak memory
footprint reached 81 GB and the system killed it after 44 s. Green: the
encode resolves, then the memory is freed, in that order. A second dispose()
returns the same Promise, later calls are refused, and a context whose last
reference is dropped mid-encode and collected settles its encode.

Guards on Metal with LLOYAL_LOCAL=1, each in its own process: unit, the text
suite with nomic (332), branch (59), images (52), audio (7), the stderr
canary, and the embedding suite (12).

The SDK declares dispose(): void, and it ships from hdk; its type follows in
the hdk layer.

Judged by hickey 0.2.1:

  Gets in: The whole makes more sense with this change: SessionContext now
  owns the single decision of when native state is freed (InFlight plus
  freeNative), every worker composes that decision through ContextWorker, and
  two teardown copies that disagreed become one.
…digest

npm run test:scenarios runs EmbeddingGemma 2 over three videos the media
pipeline ingested, each read from an OCI layout by its root digest with
every blob verified (test/scenario-pack.ts), against ground truth a person
confirmed by watching (test/scenarios/*.json: time ranges, labels and our
own descriptions, no footage and no dialogue).

- Every cat in a ten-minute pet compilation: 615 frames as images. The
  first 20 clips for "a cat" and the first 10 for "a dog" all show that
  animal; at least 0.9 and 0.8 of each animal's clips come before any
  other; a 128-wide scan rescored at 768 on the better half keeps 95% of
  the full scan's clips.
- A car in half an hour of dash-cam footage: the scene's description puts
  the moment in the first three of 1,863 frames and of 232 eight-second
  windows, at 768, 256 and 128 wide.
- Hindi speech found by an English question from its sound alone, among
  all 528 soundtrack windows of the three videos, English speech and music
  included; fused with the Hindi transcript by reciprocal rank, each
  moment comes first.

The embed step (clear, encode, read) and the rule that embeds on one
context run one at a time move to embedding-fixtures.ts, which both
embedding suites now compose, with dot.

Guards on existing capabilities, not red first. On Metal (M2 Pro,
LLOYAL_LOCAL=1): scenarios 7 of 7 in 628 s, embedding 12 of 12.

Judged by hickey 0.2.1:

  Gets in: The change moves the clear-encode-read embedding sequence and
  the one-at-a-time rule into embedding-fixtures.ts, so both suites share
  them instead of copying them, and keeps pack reading and ground truth
  with their own owners. The next suite, or the next change to how vectors
  are read, costs one edit.
…er seam

The scenario packs were rebuilt through hdk's ClipDecoder seam, so their
frames and soundtrack windows are what a page's ingest produces: frames
stamped with the time each was shown (the 29.97 fps pet compilation's at
968, 1969, 2970 ms), the soundtrack mixed and decimated by media's own
procedure. Each scenario pins its new root.

The pet compilation's ground truth splits one clip at a cut scene
detection had missed, at 120.554 s: a cat at a window, then a dog. The
dog query ranked a frame after the cut seventh, which the old label
counted as a cat; the frames show a dog.

On Metal (M2 Pro, LLOYAL_LOCAL=1): the dash-cam and Hindi scenarios pass
on the new packs, and the pet compilation's three pass after the split.

Judged by hickey 0.2.1:

  Gets in: The change edits data only within the design already in
  place: each scenario's truth file is the single owner of its pack pin
  and its source-time labels, and the edits stay there. The whole is as
  coherent as it was, and the ground truth is more correct.
The dash-cam scenario now ends where its question does. After the scene's
description finds the moment, a second query within it ("the number plate
of a red SUV") ranks the one frame the plate can be read in first, and
Qwen3.5-4B with its vision projector reads it: 01-ZAM, exactly. Asked of a
frame where the plate is a few pixels wide, it answers "unreadable": the
question gives it that way out, because asked without one it invents a
plate (six confident, wrong plates across the moment's distant frames).
The legible and illegible frames are recorded in the ground truth.

The vision model pair has one owner, test/vision-fixtures.ts, which the
integration suite's image tests now use too, and withContext moves to
local-addon.ts, typed over any context that disposes, so an embedding and
a vision context share one rule for freeing GPU memory.

On Metal (M2 Pro, LLOYAL_LOCAL=1): the dash-cam scenario 4 of 4 (510 s),
the embedding suite 12 of 12, the image suite 52 of 52 with
LLOYAL_VL_STRICT=1.

Judged by hickey 0.2.1:

  Gets in: The change gives the vision model pair one owner
  (vision-fixtures.ts) and the context lifetime one generic owner
  (withContext in local-addon.ts), then builds the plate-reading scenario
  on top of both. The next vision suite or model swap therefore touches
  one place instead of copying pickVlPair or a vision-only withContext.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Disposal from a media getter can clear native handles before worker construction, risking a process crash.

1 open issue
What changed in this PR

Adds multimodal embedding support for EmbeddingGemma 2 to the native addon and defers context cleanup until in-flight work completes.

Changes:

  • Adds encodeMultimodal and embedding-aware input measurement.
  • Gives context workers lifetime holds and makes dispose() return a shared completion promise.
  • Adds reference-vector, disposal, and media-scenario tests, plus CI integration.
File Description
tsconfig.test.json Excludes suites awaiting updated SDK types.
test/​vision-fixtures.ts Centralizes vision model paths.
test/​scenarios/​hindi-movie-dialog.json Defines Hindi speech retrieval ground truth.
test/​scenarios/​dash-cam-crashes.json Defines scene and plate-reading targets.
test/​scenarios/​cats-and-dogs.json Labels pet clips for retrieval checks.
test/​scenario-pack.ts Loads digest-verified media packs.
test/​scenario-integration.ts Tests retrieval across long media scenarios.
test/​matrix.json Adds the embedding model and projector.
test/​local-addon.ts Shares local-addon verification and cleanup helpers.
test/​integration.ts Uses shared vision model paths.
test/​embedding-integration.ts Tests embeddings, validation, and disposal.
test/​embedding-fixtures.ts Provides embedding contexts and media helpers.
test/​audio-integration.ts Reuses local-addon verification.
test/​__dispose-in-flight-child.ts Isolates in-flight disposal and collection checks.
src/​SessionContext.hpp Declares multimodal encoding and lifecycle state.
src/​SessionContext.cpp Implements encoding and deferred native cleanup.
scripts/​download-test-models.sh Documents embedding service downloads.
README.md Describes embedding integration coverage.
package.json Adds embedding and scenario test commands.
.github/​workflows/​tests.yml Runs embedding integration with a CPU timeout.

Review effort: Balanced

Comment thread src/SessionContext.cpp
…t disposes it cannot free it underneath

Reading a caller's object runs its getters, and a getter can call dispose().
With no work in flight, dispose() freed the projector, the context and the
model at once, and the call that was reading its arguments went on to use
them. encodeMultimodal, _cellsMultimodal and _storePrefillMultimodal
crashed on the freed model; formatChat and formatChatSync failed llama.cpp's
model assertion; _scoreGroup crashed on the freed context; and
_branchSetSamplerParams, _branchSetLogitBias, _branchSteer and
_branchSetGrammarLazy aborted on the drained store's handle error.
_branchPrefill, _storePrefill and _storeCommit were refused with an invalid
handle, because the drained store frees every slot before their workers look.

Every method but dispose() is now registered through held<Method>, which
takes the InFlight hold the workers already take for the span of the call.
A call that began before dispose() is in flight like any queued work:
dispose() refuses every later call, and the native state is freed when the
last hold is released, after the call and any work it queued. Freeing keeps
its one owner, InFlight and freeNative().

Red first. A getter on an argument disposes the context during the call:
- EmbeddingGemma 2, test:embedding: encodeMultimodal and _cellsMultimodal
  reading an input, and encodeMultimodal reading its audio limits. The
  process exited 139.
- Qwen3.5-4B with its projector, the image suite: _storePrefillMultimodal
  reading an input, and reading a prompt. The process exited 139 after 52
  passes.
- SmolLM2, the text suite: formatChat, formatChatSync, _scoreGroup, the four
  branch setters, _branchPrefill, _storePrefill and _storeCommit. The
  process exited 134 on the first case, at GGML_ASSERT(model != nullptr).
Green: every call settles, then the context is freed, in that order, and a
later call is refused as disposed.

Verified on Metal with LLOYAL_LOCAL=1, each suite in its own process: the
embedding suite 13 of 13, images 54, the text suite with SmolLM2 342, branch
59, audio on Qwen3-ASR 7 of 7, unit, and the stderr canary.

Cost: the hold takes and releases a reference per call. isStopToken in a
tight loop went from about 72 ns to about 116 ns per call.

The four branch setters still abort the process for any invalid handle,
disposed or not: their std::runtime_error is not translated to a JS error.

Gate: Gets in: The change puts the decision 'a running call holds the context' with the existing InFlight hold and applies it once per entry point in the registration table, which closes a use-after-free without adding a second freeing rule. The only cost: a new method has to be registered through `held<>`.
lefthook runs `hickey committing` before each commit, writes its verdict
into the commit's trailers and records it after, as in hdk. The judge
reads hickey's own principles; `kind` is framework, and judging is
required. `npm install` installs the hooks through `prepare`.

The lockfile gains only hickey, lefthook and zod. Its stubs for this
version's platform packages are left as the release workflow keeps them.

Gate: gets in. It gets in: commit-time judgment is placed in the git hook lifecycle, with lefthook owning the wiring, hickey owning the verdict and one config file owning the policy, so the next change to either the policy or the hooks touches one file. The only follow-ups are documentation: CONTRIBUTING.md doesn't mention the hook, and the comment cites a PRINCIPLES.md this repository doesn't contain.
Refused-Before: 3 refusals on the way to this shape. Last: The rule 'freed state is never touched by a call still running' belongs in the dispose/InFlight lifecycle, which counts users. Here it is re-decided by hand at 10 call sites while at least 11 other entry points with the same exposure are left open, so the author should extend InFlight to cover a synchronous entry point instead of adding re-checks one line at a time.
Gate: gets in. The gate still has one owner (lefthook.yml, installed by prepare), and this change describes it accurately in the contributor guide while removing a reference to a file that doesn't exist, so the whole system makes more sense with it.
… no hooks to install

`prepare` ran `lefthook install`, a dev dependency, on every install of the
package's own tree. The GPU test images install it with --omit=dev and no git
checkout, so their install failed with "lefthook: command not found". The hooks
are now installed only where there is a checkout and lefthook to install them;
anywhere else `prepare` does nothing and the install goes on.

Gate: gets in. The change gets in. It gives one owner, the `prepare` script, to the decision of when commit hooks get installed. Images with no dev dependencies or no git checkout now install cleanly, and how hooks work stays with lefthook.
…opies nothing else

The images copy package.json alone before they install, so a guard in
scripts/ was not there to run: prepare failed with "Cannot find module"
instead of "lefthook: command not found". The guard is now prepare's own
command, and the test installs from the manifest alone, as the images do.

Gate: gets in. The hook-install guard now lives entirely in the `prepare` lifecycle that owns it, so installs no longer depend on images copying `scripts/`. That makes the whole more coherent than today, and the only cost is the guard's explanation moving from a code comment to the test that enforces it.
…dows the whole

CI's macOS build has Metal off, since the runner's paravirtual GPU computes
wrong, so the projector runs on a three-core CPU: 40 s a clip frame, minutes an
image, ten minutes for the infographic. The whole reference is over an hour
there and the step timed out at 30 minutes, ten tests unrun. With
LLOYAL_EMBED_LEAN=1, set on macOS only, the suite checks the text, the smallest
image budget and the recording against the reference and skips the two search
tests, which read every image and the clip; every contract test still runs.

Run here on Metal: lean 11 pass, 2 skipped; whole 13 pass in 39 s.

Gate: gets in. The whole makes more sense with the change: the workflow decides which runner runs lean, the suite decides what lean leaves out, and each decision has one owner joined by one env name. The author should make the reference-check test report what lean omitted instead of keeping a title that claims every input.
…reference

Under LLOYAL_EMBED_LEAN the reference test checks a subset while its title
names every input. It now reports, in the run's output, how many entries it
checked and which it left out, and asserts the lean subset still holds an
image, so a macOS pass never reads as the whole reference.

Gate: gets in. The whole makes more sense with this change. The lean subset still has one owner, the `checked` filter. The test now guards that the subset keeps an image and reports what it skipped, derived from that filter, so lean runs can neither silently drop images nor hide what they skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-cutting native lifetime changes and pending upstream integration require human validation with the native regression suites.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

macOS's CI step timed out in the dispose child: the lean setting reached it
but it ignored it, encoding the infographic and the clip twice, over two
thousand cells each, on a CPU runner. The property needs an encode in flight
when the context is freed or collected, not the largest one. Lean, the parent
now names the lean reference's image and budget, and checks the child encoded
no more than that; every lifecycle assertion stays.

Red on Metal: 2407 cells against the lean image's 64. Green: the case in 4.7 s;
the suite lean 11 passed, 2 skipped (search), and whole 13 of 13.

Gate: gets in. The whole makes more sense with this change, because the dispose case's input now follows the one existing lean selection (`checked`/`smallestBudget`) through a narrow argv contract. The tautological `cells` re-measure in the parent and the child's `cells` field could be dropped with nothing lost.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-cutting native lifetime changes and pending upstream integration require human validation with the native regression suites.

No open issues 🎉

Review effort: Balanced

Gate: gets in. This submodule bump changes only the liblloyal version, through the gitlink that owns it, and the llama.cpp invariant still has its owner and its CI check. So the whole is as coherent as before, with the newer core. One caveat: I couldn't inspect the submodule's contents, so if .llama-cpp-version moved, the llama.cpp gitlink has to move in the same commit.
@lloyal-research
lloyal-research marked this pull request as ready for review October 26, 2026 11:40
Gate: gets in. The bump changes the liblloyal version only at its single owner, the gitlink, and leaves the llama.cpp pin derived from liblloyal's `.llama-cpp-version` and checked by CI, so ownership stays as coherent as it was. The caveat is that the submodule's own changes could not be inspected, and if they moved the llama.cpp tag, the llama.cpp gitlink has to be synced before this passes CI.
@lloyal-research
lloyal-research merged commit 9d88fcd into main Oct 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants