Skip to content

refactor(binding-echo): extract semantic-reject into model-vector - #2445

Open
jfallows wants to merge 18 commits into
developfrom
claude/github-issue-1138-0vvnk2
Open

refactor(binding-echo): extract semantic-reject into model-vector#2445
jfallows wants to merge 18 commits into
developfrom
claude/github-issue-1138-0vvnk2

Conversation

@jfallows

Copy link
Copy Markdown
Contributor

Description

binding-echo previously carried bespoke embedding:/reject:/threshold: options and its own inline moderation logic. That's a layering violation: echo is a transport-level example binding, and semantic rejection is business logic that belongs behind the existing generic model extension point (model-json, model-avro, model-protobuf, model-core), not bolted onto one binding.

This PR extracts that logic into a new incubator model, model-vector, which rejects a value whose embedding is similar enough to a configured list of reject phrases. Any binding that already supports an inline model: reference gets this behavior for free; binding-echo is ported onto it as the first consumer, driving the model generically through options.model instead of embedding-specific options.

The blocking gap: ModelPipeline/ModelTransform was fully synchronous, but an embedding lookup is inherently async. This PR adds a minimal, additive ModelStatus.SUSPENDED plus a 3-arg supplyDecoder/supplyEncoder(envelope, transform, resumed) overload to ModelHandler, so a model can suspend mid-transform and resume via a callback dispatched back onto the correct engine worker thread — with zero changes required to model-json/model-avro/model-protobuf/model-core.

model-vector resolves its referenced embedding's engine-scoped id via the engine's existing generic NamedConfig/refs() resolution mechanism (a new EmbeddedConfig type, mirroring VaultedConfig/GuardedConfig) rather than threading a new lookup path through engine SPI.

The engine's own type: test model and type: test binding test doubles are extended additively (all new fields/behavior default off, so every existing consumer is unaffected) to support SUSPENDED, so both binding-echo's and model-vector's own k3po integration tests can genuinely exercise the async suspend/resume path against a live engine without depending on each other's concrete modules. This surfaced and fixes a real bug in the test binding: it was tearing down a stream's decode slot on END/ABORT while a model was still awaiting async resolution.

Test plan

  • Unit tests for model-vector (accept / reject / suspend-resume) using the engine's TestEmbeddingHandler
  • model-vector's own k3po IT (VectorModelIT) using the engine's generic type: test binding, exercising the real async suspend/resume path against a live engine
  • binding-echo's k3po ITs ported to the new options.model shape, using the engine's type: test model (extended with reject/suspend) instead of a concrete model dependency
  • runtime/engine full verify (211 ITs) green after extending TestBindingFactory/TestModelPipeline
  • Full reactor ./mvnw clean install green, including cloud/docker-image
  • Manual end-to-end smoke test: rebuilt the tcp.echo.embedding example's Docker image and exercised accept / reject-by-exact-phrase / reject-by-semantic-paraphrase / control scenarios against the real embedding-glove backend (not a test double)

🤖 Generated with Claude Code


Generated by Claude Code

claude added 18 commits August 28, 2026 00:07
Introduces Embedding/EmbeddingHandler/EmbeddingContext/EmbeddingFactorySpi
in runtime/engine, sibling to catalogs:/vaults:/guards:/stores:, plus the
embeddings: config schema, EngineConfig plumbing, and a TestEmbedding
engine-test-jar implementation, mirroring the existing catalog wiring
end-to-end (EngineBuilder, EngineWorker, EngineRegistry, NamespaceRegistry,
EngineContext).

EmbeddingHandler.embed() supports both a synchronous local-only fast path
and an async CompletionCallback-based variant (modeled on
GuardHandler.reauthorize's sync/async split), so an implementation backed
by a remote embedding API can resolve a vector without blocking the
engine's reactor thread.

Scoped to the core SPI + config + schema + test-jar; no embedding
implementation module or binding-echo consumer yet.
Drop the synchronous fast-path overload and the NONE placeholder from
EmbeddingHandler — a single async embed() is a smaller, less confusing
contract, and NONE could not honor the strictly-later completion
guarantee without a worker thread to dispatch onto. embed() now always
defers to the next event-loop tick via EngineContext.dispatch(), the
same mechanism StoreHandler and the async GuardHandler.reauthorize are
built on; TestEmbeddingContext already wired its dispatcher that way.
Gives binding-echo an options: embedding:/reject:/threshold: block. When
configured, each inbound message is embedded via the referenced embeddings:
instance and compared by cosine similarity against a set of configured
reject exemplars (also embedded once, lazily, with pending checks queued
until ready). A message scoring at or above threshold against any exemplar
ends the stream via doEnd() (a policy decision, not a protocol violation)
instead of being echoed; everything else echoes exactly as before.

Adds the echo.schema.patch.json options schema, EchoOptionsConfig/-Builder/
-Adapter in config/binding-echo.conf, and paired k3po client/server scripts
plus spec-level (self-consistency) and runtime-level (live engine, driven
by the engine's TestEmbedding double) IT coverage for both the reject and
pass-through paths.
Adds embedding-djl.spec/.conf/(runtime) implementing the embeddings:
"djl" type via DJL (Deep Java Library)'s Criteria/Predictor/
TextEmbeddingTranslatorFactory against sentence-transformers/
all-MiniLM-L6-v2 on DJL's PyTorch engine. Model resolution is
auto-download via DJL's model zoo (djl:// URL), version-pinned,
matching the incubator's lower offline/air-gapped bar.

DJL and its transitive dependencies (jna, gson, commons-compress +
transitives, slf4j-api) ship as automatic-module-only jars with no
module-info.class, which conflicts with this repo's fully-modular
JPMS discipline. Rather than accept an automatic-module dependency
or wrap each third party jar in its own synthesizing shim module,
embedding-djl shades and relocates the whole DJL dependency closure
into its own module jar under an internal vendor package, so the
module-info.java requires nothing beyond runtime engine and its own
config module — verified via ModuleFinder that the resulting module
is a complete, self-contained 146-package JPMS module.

EmbeddingHandler.embed() runs DJL's blocking Predictor.predict() on a
dedicated background thread, then hops back onto the calling engine
worker via EngineContext.dispatch() to invoke the completion callback,
honoring the strictly-later async contract without native inference
ever blocking a worker thread.

The narrow unit test verifies Criteria construction (engine, types,
translator factory) without loading a real model, so it needs no
network access or native library.
Adds runtime/common-vector, a small allocation-conscious utility for
operations on fixed-size float[] vectors: similarity() (cosine, named
generically since it's the only metric today) and normalize() (L2).
similarity() never requires pre-normalized inputs -- it divides by
both vectors' magnitudes internally -- so normalize() stays a fully
independent, optional transform for callers optimizing repeated
comparisons via plain dot product, never a hidden requirement of the
concept an embedding vector represents.

Refactors binding-echo's moderation feature to call
Vectors.similarity() instead of its own private cosineSimilarity(),
removing the duplicate implementation -- verified via binding-echo's
full IT suite (6/6 passing) plus common-vector's own unit tests.
…flag

DjlEmbeddingInfo was missing @Incubating, so type: djl was always
accepted by the config schema regardless of ZILLA_INCUBATOR_ENABLED,
bypassing the incubator safety gate that every other incubator
component's *Info class already honors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD1DvMrvh3ypdadAYggTGu
Demonstrates the pluggable embeddings: concept end-to-end with a
moderated echo server backed by embedding-djl (local MiniLM via DJL,
no vendor API). Adds the missing SchemaTest for embedding-djl.conf's
djl.schema.patch.json (mirrors catalog-inline.spec's pattern) and
wires embedding-djl + common-vector into the Docker image's zpm
manifest so the example's incubator dependency is actually packaged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD1DvMrvh3ypdadAYggTGu
DJL's HuggingFace model-zoo resolution embeds package-shaped groupId
strings directly into outbound HTTP requests to mlrepo.djl.ai — relocating
those strings (required to keep the shaded module JPMS-clean) silently
breaks that live catalog lookup, since the external server only recognizes
the original, unrelocated names. That's a hard conflict between shading
and DJL's HuggingFace resolution feature, not something fixable from our
side. GloVe word-vector averaging needs only java.net.http (JDK-bundled),
so it sidesteps shading, native libraries, and the JPMS ServiceLoader
gymnastics entirely, at the cost of coarser semantic matching that is an
acceptable tradeoff for an OSS example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD1DvMrvh3ypdadAYggTGu
…at DJL classes

The earlier embedding-djl -> embedding-glove rename only rewrote *.java,
*.xml, *.json, and *.yaml file contents; these two extension-less
META-INF/services files kept naming the old Djl* classes as the fully
qualified provider, which is what the runtime image's service-provider
binding table actually reads. That's the real root cause of the
"Provider ... DjlEmbeddingFactorySpi not found" failure seen when
running the packaged image, reproducing on every zilla.yaml
(EmbeddingFactory.instantiate() runs unconditionally at engine startup)
regardless of whether embeddings: is configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD1DvMrvh3ypdadAYggTGu
The previous regeneration used a non-recursive install, which lacked
enough reactor context to see binding-pgsql-kafka/binding-risingwave's
transitive dependencies (binding-kafka.spec, binding-proxy.spec,
engine.spec) and their own transitive third-party licenses. A full
clean install from repo root produces the complete, correct listing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD1DvMrvh3ypdadAYggTGu
…ctors

nlp.stanford.edu 301-redirects glove.6B.zip requests to
downloads.cs.stanford.edu. HttpClient's default redirect policy is NEVER,
so the redirect response body (not the zip) was being saved to the cache
file, which then failed to parse as a zip.
cloud/docker-image sorts before incubator/embedding-glove in the reactor
build order, and Maven only installs a module to the local repo once its
own build completes. Without an explicit dependency, docker-image's
assembly step ran before embedding-glove was installed, so the artifact
was missing from the image's bundled repo; zpmw install then tried (and
failed) to resolve it from the remote registry instead. Declaring the
dependency here, matching every other incubator binding, forces Maven to
build embedding-glove first.
io.aklivity.zilla.config.binding.echo never declared an exports clause,
unlike every sibling *.conf module (e.g. binding-tcp.conf). This was
latent while EchoOptionsConfig went unread across the module boundary;
once EchoServerFactory needed to read its embedding/reject/threshold
fields directly, module loading failed with:
IllegalAccessError: class EchoServerFactory (in module
io.aklivity.zilla.runtime.binding.echo) cannot access class
EchoOptionsConfig (in module io.aklivity.zilla.config.binding.echo)
because module io.aklivity.zilla.config.binding.echo does not export
io.aklivity.zilla.config.binding.echo to module
io.aklivity.zilla.runtime.binding.echo
…e directory

Previously cached to java.io.tmpdir, which is ephemeral across container
recreation in a real deployment, forcing a redownload of the 862MB GloVe
zip every restart. Mirrors binding-kafka's local cache convention: adds
GloveEmbeddingConfiguration.cacheDirectory(), resolved from
EngineConfiguration.ENGINE_CACHE_DIRECTORY (a "glove" subdirectory), and
threads it through GloveEmbedding -> GloveEmbeddingContext ->
GloveEmbeddingHandler instead of System.getProperty("java.io.tmpdir").

Also mounts a Docker volume over the cache directory in the
tcp.echo.embedding example so the download survives compose down/up.
0.85 was tuned for MiniLM sentence-transformer embeddings and didn't
carry over to GloVe's mean-pooled word vectors, which compress this
demo's phrases into a much narrower, higher band: the accepted
"stray cat" message scored 0.92-0.93 against the reject phrases, above
the old 0.85 threshold, so it was incorrectly rejected.

Verified empirically against the real downloaded vectors: the accepted
phrase's highest similarity to either reject phrase is 0.927, and each
reject phrase (including the same-pattern-different-vocabulary one)
scores at least 0.954 against one of the reject phrases. 0.94 sits
cleanly between the two with margin on both sides. Confirmed against
the full accept/reject/control matrix and the example's own
.github/test.sh, all passing.
…vector

Introduce a new incubator/model-vector model that rejects a value whose
embedding is similar enough to a configured list of reject phrases, and
port binding-echo onto it via the generic options.model mechanism instead
of embedding-specific echo options. Any binding that already supports an
inline model reference gets this behavior for free.

The blocking gap was that ModelPipeline/ModelTransform was fully
synchronous, but embedding lookups are async-only. Add a minimal,
additive ModelStatus.SUSPENDED plus a 3-arg
supplyDecoder/supplyEncoder(envelope, transform, resumed) overload to
ModelHandler so a model can suspend mid-transform and resume via a
callback dispatched back onto the correct engine worker thread -- with
zero changes to model-json/model-avro/model-protobuf/model-core.

Resolve the embedding's engine-scoped id via the existing generic
NamedConfig/refs() resolution mechanism (new EmbeddedConfig type) rather
than threading bindingId through new engine SPI overloads.

Extend the engine's own type: test model and type: test binding test
doubles (additively, all-new fields default off) to support SUSPENDED,
so binding-echo's and model-vector's own k3po ITs can genuinely exercise
the async suspend/resume path against a live engine without depending on
each other's concrete modules. Along the way, fix a real bug this
surfaced: TestBindingFactory was tearing down a stream's decode slot on
END/ABORT while a model was still awaiting async resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GD1PunXvfUv4Y69nB6YDUY
Avoid the model/model stutter in echo's options (options.model.model:
vector) by renaming the outer field to value, matching the engine's own
type: test binding precedent for "apply a model to the whole payload"
(options.value: { model: <type>, ... }) and MqttTopicConfig.content's
existing convention of never repeating "model" as both the outer field
name and the model's own type discriminator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GD1PunXvfUv4Y69nB6YDUY
…loVe download

CI job "testing (tcp.echo.embedding)" failed: docker compose up -d --wait
reported the zilla container unhealthy after ~87s, while the GloVe vector
download it waits on took several minutes to complete (observed ~9 minutes
under similar conditions). The old healthcheck budget (start_period 15s +
retries 15 x interval 5s ~= 90s) was sized for an already-cached download,
not a cold one. Widen it to start_period 30s + retries 60 x interval 15s
~= 15.5 minutes so the first run in a fresh environment has room to finish.
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.

2 participants