Skip to content

Commit f83bdf2

Browse files
lalaluneclaude
andcommitted
feat(elizainference): ABI v11 — fused end-of-turn scoring
Add eliza_inference_llm_eot_supported + eliza_inference_llm_eot_score: a single causal forward pass over a pre-tokenized partial transcript that returns the next-token softmax probability of the end-of-turn marker (e.g. <|im_end|>), plus the optional argmax token + prob. This is the fused replacement for the retired node-llama-cpp controlledEvaluate() path the EOT classifiers (Eliza1EotScorer, LiveKitGgmlTurnDetector) needed. Runs on a dedicated CAUSAL scoring context over the resident text model (logits at the final position), lazily created and reused, KV cleared per call so each score is independent — distinct from the non-causal pooled embed_ctx and the per-session streaming-LLM KV. Mirrors the v9 embed() context lifecycle. Bumps ELIZA_INFERENCE_ABI_VERSION 10 -> 11 (additive; a v10 library reports llm_eot_supported()==0 and the loader keeps the heuristic). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a0c0790 commit f83bdf2

2 files changed

Lines changed: 205 additions & 2 deletions

File tree

tools/omnivoice/include/eliza-inference-ffi.h

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ extern "C" {
131131
* load and refuses to bind if they disagree.
132132
*
133133
* Changelog:
134+
* v11: end-of-turn scoring folded in-process. `eliza_inference_llm_eot_supported()`
135+
* + `_llm_eot_score` run a single causal forward pass over a tokenized
136+
* partial transcript and read the next-token probability of the
137+
* end-of-turn marker (e.g. <|im_end|>), replacing the retired
138+
* node-llama-cpp `controlledEvaluate()` the EOT classifiers needed. The
139+
* model-based EOT detector now runs through the fused lib instead of a
140+
* JS-only heuristic. Additive symbols — a v10 caller is unaffected; a
141+
* v10 library reports `llm_eot_supported() == 0` and the loader keeps
142+
* the heuristic classifier.
134143
* v10: Kokoro-82M TTS folded in-process. `eliza_inference_kokoro_supported()`
135144
* + `_load` + `_synthesize` + `_sample_rate` link kokoro_lib (its own
136145
* GGUF reader + iSTFT decoder) into the fused handle so the mobile
@@ -177,9 +186,9 @@ extern "C" {
177186
* v7: real Silero VAD (same symbol surface as v6).
178187
* v6: fused wake-word, speaker, diarizer.
179188
*/
180-
#define ELIZA_INFERENCE_ABI_VERSION 10
189+
#define ELIZA_INFERENCE_ABI_VERSION 11
181190

182-
/* Returns a static, NUL-terminated string of the form "10" matching
191+
/* Returns a static, NUL-terminated string of the form "11" matching
183192
* ELIZA_INFERENCE_ABI_VERSION at the time the library was built. The
184193
* pointer is owned by the library — do NOT free. */
185194
const char * eliza_inference_abi_version(void);
@@ -932,6 +941,41 @@ int eliza_inference_embed(
932941
int * out_dim,
933942
char ** out_error);
934943

944+
/* ---- End-of-turn scoring (ABI v11, additive) --------------------- *
945+
*
946+
* Score whether the user has finished their turn by reading the next-token
947+
* probability of the chat template's end-of-turn marker (e.g. <|im_end|>)
948+
* after a partial ASR transcript. This is the fused replacement for the
949+
* retired node-llama-cpp `controlledEvaluate()` path the EOT classifiers used:
950+
* the JS side formats the partial transcript as a user turn, tokenizes it via
951+
* `eliza_inference_tokenize`, looks up the end-of-turn token id, and reads back
952+
* P(end-of-turn). Runs on a dedicated CAUSAL context over the resident text
953+
* model (logits at the final position), lazily created and reused, KV cleared
954+
* per call so each score is independent. A v10 library does not export these
955+
* symbols, so absence == unsupported and the loader keeps the heuristic EOT
956+
* classifier.
957+
*/
958+
959+
/* Capability probe: 1 when this build wires the real EOT scoring path. */
960+
int eliza_inference_llm_eot_supported(void);
961+
962+
/* Single causal forward pass over `token_ids` (`num_tokens` int32s the library
963+
* copies). Writes the next-token softmax probability of `target_token_id` into
964+
* `*out_target_prob`. Optionally also writes the argmax next-token id into
965+
* `*out_top_token` and its probability into `*out_top_prob` (pass NULL to skip
966+
* either). The context is truncated to its scoring window from the TAIL when it
967+
* overflows. Returns ELIZA_OK or a negative ELIZA_* code with `*out_error`
968+
* populated. */
969+
int eliza_inference_llm_eot_score(
970+
EliInferenceContext * ctx,
971+
const int32_t * token_ids,
972+
size_t num_tokens,
973+
int32_t target_token_id,
974+
float * out_target_prob,
975+
int32_t * out_top_token,
976+
float * out_top_prob,
977+
char ** out_error);
978+
935979
/* ---- mmproj vision describe (ABI v9, additive) -------------------- *
936980
*
937981
* Describe an image with the TEXT model + its mmproj projector, reusing the

tools/omnivoice/src/eliza-inference-ffi.cpp

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ struct EliInferenceContext {
124124
llama_context * embed_ctx = nullptr;
125125
int embed_pooling = -1; /* the pooling type embed_ctx was built with */
126126
int embed_n_ctx = 0; /* the ctx size embed_ctx was built with */
127+
/* Dedicated CAUSAL scoring context over the shared text model (ABI v11).
128+
* Lazily created on the first eliza_inference_llm_eot_score call. EOT
129+
* scoring needs the causal next-token logit distribution at the final
130+
* position (the fused replacement for the retired node-llama-cpp
131+
* controlledEvaluate() the EOT classifiers used) — distinct from embed_ctx
132+
* (non-causal + pooled, exposes no per-token logits) and from the
133+
* per-session streaming-LLM KV. KV is cleared per call so each score is
134+
* independent. Protected by llm_mutex. */
135+
llama_context * eot_ctx = nullptr;
136+
int eot_n_ctx = 0; /* the ctx size eot_ctx was built with */
127137
/* mmproj vision context over the shared text model (ABI v9), keyed by the
128138
* mmproj path it was initialized from. Lazily created on the first
129139
* eliza_inference_describe_image call per mmproj_path and reused.
@@ -1519,6 +1529,10 @@ void eliza_inference_destroy(EliInferenceContext * ctx) {
15191529
llama_free(ctx->embed_ctx);
15201530
ctx->embed_ctx = nullptr;
15211531
}
1532+
if (ctx->eot_ctx) {
1533+
llama_free(ctx->eot_ctx);
1534+
ctx->eot_ctx = nullptr;
1535+
}
15221536
if (ctx->llm_model) {
15231537
llama_model_free(ctx->llm_model);
15241538
ctx->llm_model = nullptr;
@@ -3181,6 +3195,151 @@ int eliza_inference_embed(
31813195
return ELIZA_OK;
31823196
}
31833197

3198+
/* ---- End-of-turn scoring (ABI v11) -------------------------------- *
3199+
*
3200+
* Single causal forward pass over a pre-tokenized context, returning the
3201+
* next-token softmax probability of `target_token_id` (the chat template's
3202+
* end-of-turn marker, e.g. <|im_end|>). This is the fused replacement for the
3203+
* retired node-llama-cpp `controlledEvaluate()` the EOT classifiers depended
3204+
* on: the JS side formats the partial ASR transcript as a user turn, tokenizes
3205+
* it via eliza_inference_tokenize, and reads back P(end-of-turn). Runs on a
3206+
* DEDICATED causal context (logits at the final position), lazily created and
3207+
* reused, KV cleared per call so scores are independent. Does not touch the
3208+
* streaming-LLM generation context or the embedding context.
3209+
*/
3210+
3211+
int eliza_inference_llm_eot_supported(void) {
3212+
return 1;
3213+
}
3214+
3215+
/* Build (or reuse) the dedicated causal scoring context. Caller must hold
3216+
* ctx->llm_mutex and have a resident ctx->llm_model. Causal layout (no
3217+
* embeddings, no pooling) so the next-token logit distribution at the final
3218+
* position is readable via llama_get_logits_ith. */
3219+
static int eliza_ensure_eot_ctx_locked(
3220+
EliInferenceContext * ctx,
3221+
char ** out_error) {
3222+
if (ctx->eot_ctx) return ELIZA_OK;
3223+
3224+
const int n_ctx_train = llama_model_n_ctx_train(ctx->llm_model);
3225+
int n_ctx = eliza_int_env_or_default("ELIZA_EOT_N_CTX", 512);
3226+
if (n_ctx_train > 0 && n_ctx > n_ctx_train) n_ctx = n_ctx_train;
3227+
3228+
llama_context_params cparams = llama_context_default_params();
3229+
cparams.n_ctx = (uint32_t) n_ctx;
3230+
cparams.n_batch = (uint32_t) n_ctx;
3231+
cparams.n_ubatch = (uint32_t) n_ctx;
3232+
cparams.n_threads = eliza_thread_count(false);
3233+
cparams.n_threads_batch = eliza_thread_count(true);
3234+
cparams.embeddings = false; /* causal generation layout, per-token logits */
3235+
3236+
llama_context * lctx = llama_init_from_model(ctx->llm_model, cparams);
3237+
if (!lctx) {
3238+
eliza_set_error(out_error,
3239+
"[libelizainference] eot: failed to init scoring context");
3240+
return ELIZA_ERR_FFI_FAULT;
3241+
}
3242+
ctx->eot_ctx = lctx;
3243+
ctx->eot_n_ctx = n_ctx;
3244+
return ELIZA_OK;
3245+
}
3246+
3247+
int eliza_inference_llm_eot_score(
3248+
EliInferenceContext * ctx,
3249+
const int32_t * token_ids,
3250+
size_t num_tokens,
3251+
int32_t target_token_id,
3252+
float * out_target_prob,
3253+
int32_t * out_top_token,
3254+
float * out_top_prob,
3255+
char ** out_error) {
3256+
if (out_target_prob) *out_target_prob = 0.0f;
3257+
if (out_top_token) *out_top_token = -1;
3258+
if (out_top_prob) *out_top_prob = 0.0f;
3259+
3260+
if (!ctx || !token_ids || num_tokens == 0 || !out_target_prob) {
3261+
eliza_set_error(out_error,
3262+
"[libelizainference] eot: invalid arguments");
3263+
return ELIZA_ERR_INVALID_ARG;
3264+
}
3265+
3266+
std::lock_guard<std::mutex> lock(ctx->llm_mutex);
3267+
int rc = eliza_load_llm_model_locked(ctx, /* n_gpu_layers= */ -1, out_error);
3268+
if (rc != ELIZA_OK) return rc;
3269+
rc = eliza_ensure_eot_ctx_locked(ctx, out_error);
3270+
if (rc != ELIZA_OK) return rc;
3271+
3272+
const llama_vocab * vocab = llama_model_get_vocab(ctx->llm_model);
3273+
const int n_vocab = llama_vocab_n_tokens(vocab);
3274+
if (target_token_id < 0 || target_token_id >= n_vocab) {
3275+
eliza_set_error(out_error,
3276+
"[libelizainference] eot: target_token_id " +
3277+
std::to_string(target_token_id) + " out of range [0," +
3278+
std::to_string(n_vocab) + ")");
3279+
return ELIZA_ERR_INVALID_ARG;
3280+
}
3281+
3282+
/* Keep the TAIL when the context overflows the scoring ctx — the most
3283+
* recent tokens drive the turn-completion decision. */
3284+
size_t n_tok = num_tokens;
3285+
const int32_t * toks = token_ids;
3286+
if (n_tok > (size_t) ctx->eot_n_ctx) {
3287+
toks = token_ids + (n_tok - (size_t) ctx->eot_n_ctx);
3288+
n_tok = (size_t) ctx->eot_n_ctx;
3289+
}
3290+
3291+
/* Fresh KV per call so a previous score can't bleed into this one. */
3292+
llama_memory_clear(llama_get_memory(ctx->eot_ctx), true);
3293+
llama_set_embeddings(ctx->eot_ctx, false);
3294+
3295+
std::vector<llama_token> tokens(toks, toks + n_tok);
3296+
llama_batch batch = llama_batch_get_one(tokens.data(), (int32_t) n_tok);
3297+
const int decode_rc = llama_decode(ctx->eot_ctx, batch);
3298+
if (decode_rc != 0) {
3299+
eliza_set_error(out_error,
3300+
"[libelizainference] eot: llama_decode rc=" +
3301+
std::to_string(decode_rc));
3302+
return ELIZA_ERR_FFI_FAULT;
3303+
}
3304+
3305+
/* Next-token logits at the final position (llama_batch_get_one enables
3306+
* logits on the last token). Softmax in a numerically-stable pass: read the
3307+
* argmax and the target probability. */
3308+
const float * logits = llama_get_logits_ith(ctx->eot_ctx, -1);
3309+
if (!logits) {
3310+
eliza_set_error(out_error,
3311+
"[libelizainference] eot: llama_get_logits_ith returned NULL");
3312+
return ELIZA_ERR_FFI_FAULT;
3313+
}
3314+
3315+
float max_logit = logits[0];
3316+
int32_t top_token = 0;
3317+
for (int i = 1; i < n_vocab; ++i) {
3318+
if (logits[i] > max_logit) {
3319+
max_logit = logits[i];
3320+
top_token = i;
3321+
}
3322+
}
3323+
double sum_exp = 0.0;
3324+
for (int i = 0; i < n_vocab; ++i) {
3325+
sum_exp += std::exp((double) (logits[i] - max_logit));
3326+
}
3327+
if (sum_exp <= 0.0) {
3328+
eliza_set_error(out_error,
3329+
"[libelizainference] eot: degenerate logit distribution");
3330+
return ELIZA_ERR_FFI_FAULT;
3331+
}
3332+
const double target_p =
3333+
std::exp((double) (logits[target_token_id] - max_logit)) / sum_exp;
3334+
/* The argmax carries the max logit, so its unnormalized weight is exp(0)=1. */
3335+
const double top_p = 1.0 / sum_exp;
3336+
3337+
*out_target_prob = (float) target_p;
3338+
if (out_top_token) *out_top_token = top_token;
3339+
if (out_top_prob) *out_top_prob = (float) top_p;
3340+
return ELIZA_OK;
3341+
}
3342+
31843343
/* ---- mmproj vision describe (ABI v9) ------------------------------ *
31853344
*
31863345
* Describe an image through the text model's mmproj projector, reusing the

0 commit comments

Comments
 (0)