@@ -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