diff --git a/README.md b/README.md index 503f00c63..a06db73d9 100644 --- a/README.md +++ b/README.md @@ -1592,12 +1592,24 @@ Enable it with: ./ds4-server --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192 ``` -The cache key is the SHA1 of the rendered byte prefix, and files are named -`.kv`. The DS4 payload still stores the exact token IDs and graph state -for that prefix. This matters for continued chats: the model may have generated -one token whose decoded text is later sent back by a client as two canonical -prompt tokens. A rendered byte-prefix hit can still reuse the checkpoint and -tokenize only the new suffix. +Cache files are named `.kv`, where the digest identifies the lookup-key +prefix. Text keys are the rendered prompt bytes. Conditioned keys prepend image +span identities to those bytes. The DS4 payload still stores the exact token IDs +and graph state for that prefix. This matters for continued chats: the model may +have generated one token whose decoded text is later sent back by a client as +two canonical prompt tokens. A rendered byte-prefix hit can still reuse the +checkpoint and tokenize only the new suffix. + +Text and multimodal checkpoints participate in the same longest-compatible- +prefix search. Image-conditioned entries additionally record each image's token +span and a SHA-256 fingerprint of the actual floating-point conditioning rows. +If a request appends an image, a text checkpoint can therefore be reused up to +the image's start. Likewise, a checkpoint for image A can be reused when image B +is appended later. When an image changes, moves, or disappears, only a compatible +checkpoint ending no later than that image's start may be reused; a checkpoint +is never resumed from the middle of an image block. Existing text-only cache +files remain compatible. + The file is intentionally written with ordinary `read`/`write` I/O, not `mmap`, so restoring cache entries does not add more VM mappings to a process that already maps the model. diff --git a/ds4.c b/ds4.c index 91ab214ab..7822d31c7 100644 --- a/ds4.c +++ b/ds4.c @@ -54148,6 +54148,8 @@ typedef struct { uint32_t token_start; uint32_t token_count; uint8_t fingerprint[32]; + uint8_t state_fingerprint[32]; + bool state_fingerprint_valid; } ds4_vision_identity; struct ds4_session { @@ -64066,6 +64068,9 @@ static int ds4_prompt_append_deepseek4_vision( free(embedding->data); embedding->data = block; embedding->token_count = layout.token_count; + memset(embedding->state_fingerprint, 0, + sizeof(embedding->state_fingerprint)); + embedding->state_fingerprint_valid = false; embedding->layout = 0; embedding->grid_width = 0; embedding->grid_height = 0; @@ -66227,26 +66232,60 @@ static void ds4_session_note_prefill_progress(void *ud, const char *event, int c * * A non-matching prompt discards the checkpoint and prefills from token zero. */ -static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, char *err, size_t errlen); +static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, + char *err, size_t errlen, + int *retained_tokens); -static bool ds4_session_vision_prefix_matches( +static int ds4_session_sync_preflight(ds4_session *s, + const ds4_tokens *prompt, + char *err, size_t errlen) { + if (!s || !prompt) { + snprintf(err, errlen, "missing session or prompt"); + return 1; + } + if (prompt->len <= 0) { + snprintf(err, errlen, "empty prompt"); + return 1; + } + if (prompt->len >= s->ctx_size) { + snprintf(err, errlen, + "prompt length %d exceeds context %d (one token of generation room is required)", + prompt->len, s->ctx_size); + return 1; + } + if (ds4_session_cancelled(s)) { + snprintf(err, errlen, "interrupted"); + return DS4_SESSION_SYNC_INTERRUPTED; + } + return 0; +} + +bool ds4_session_vision_prefix_matches( const ds4_session *s, const ds4_vision_span *images, size_t image_count) { if (!s || (image_count != 0 && !images)) return false; if (!s->checkpoint_valid) return true; if (s->checkpoint_image_count > image_count) return false; - for (size_t i = 0; i < s->checkpoint_image_count; i++) { - const ds4_vision_identity *old = &s->checkpoint_images[i]; + uint64_t previous_end = 0; + for (size_t i = 0; i < image_count; i++) { const ds4_vision_span *current = &images[i]; - if (old->token_start != current->token_start || - old->token_count != current->embedding.token_count || - memcmp(old->fingerprint, current->embedding.fingerprint, - sizeof(old->fingerprint)) != 0) return false; - } - if (s->checkpoint_image_count < image_count) { - const ds4_vision_span *next = &images[s->checkpoint_image_count]; - if (next->token_start < (uint32_t)s->checkpoint.len) return false; + const uint64_t end = (uint64_t)current->token_start + + current->embedding.token_count; + if (current->embedding.token_count == 0 || + current->token_start < previous_end) return false; + previous_end = end; + + if (i < s->checkpoint_image_count) { + const ds4_vision_identity *old = &s->checkpoint_images[i]; + if (old->token_start != current->token_start || + old->token_count != current->embedding.token_count || + end > (uint64_t)s->checkpoint.len || + memcmp(old->fingerprint, current->embedding.fingerprint, + sizeof(old->fingerprint)) != 0) return false; + } else if (current->token_start < (uint32_t)s->checkpoint.len) { + return false; + } } return true; } @@ -66264,6 +66303,60 @@ bool ds4_session_has_vision_state(const ds4_session *s) { return s && (s->checkpoint_image_count != 0 || s->sync_image_count != 0); } +size_t ds4_session_vision_identity_count(const ds4_session *s) { + return s ? s->checkpoint_image_count : 0; +} + +bool ds4_session_vision_identity(const ds4_session *s, size_t index, + uint32_t *token_start, + uint32_t *token_count, + uint8_t state_fingerprint[32]) { + if (!s || index >= s->checkpoint_image_count) return false; + const ds4_vision_identity *image = &s->checkpoint_images[index]; + if (!image->state_fingerprint_valid) return false; + if (token_start) *token_start = image->token_start; + if (token_count) *token_count = image->token_count; + if (state_fingerprint) + memcpy(state_fingerprint, image->state_fingerprint, 32); + return true; +} + +bool ds4_session_restore_vision_identities(ds4_session *s, + const ds4_vision_span *images, + size_t image_count) { + if (!s || !s->checkpoint_valid || image_count == 0 || !images) return false; + if (image_count > SIZE_MAX / sizeof(s->checkpoint_images[0])) return false; + + uint64_t previous_end = 0; + for (size_t i = 0; i < image_count; i++) { + const uint64_t start = images[i].token_start; + const uint64_t count = images[i].embedding.token_count; + const uint64_t end = start + count; + if (!images[i].embedding.state_fingerprint_valid || count == 0 || + start < previous_end || end > (uint64_t)s->checkpoint.len) + return false; + previous_end = end; + } + + ds4_vision_identity *copy = calloc(image_count, sizeof(copy[0])); + if (!copy) return false; + for (size_t i = 0; i < image_count; i++) { + copy[i].token_start = images[i].token_start; + copy[i].token_count = images[i].embedding.token_count; + memcpy(copy[i].fingerprint, images[i].embedding.fingerprint, + sizeof(copy[i].fingerprint)); + memcpy(copy[i].state_fingerprint, + images[i].embedding.state_fingerprint, + sizeof(copy[i].state_fingerprint)); + copy[i].state_fingerprint_valid = + images[i].embedding.state_fingerprint_valid; + } + free(s->checkpoint_images); + s->checkpoint_images = copy; + s->checkpoint_image_count = image_count; + return true; +} + static bool ds4_session_vision_range_overlaps( const ds4_session *s, uint32_t token_start, @@ -66279,18 +66372,49 @@ static bool ds4_session_vision_range_overlaps( return false; } -static bool ds4_session_store_vision_identities(ds4_session *s) { +/* retained_tokens describes the prefix the backend actually kept, not merely + * a token match observed before sync. -1 means that sync cannot attest its + * image-state provenance (for example, a distributed recovery). */ +static bool ds4_session_store_vision_identities(ds4_session *s, + int retained_tokens) { ds4_vision_identity *copy = NULL; if (s->sync_image_count != 0) { if (s->sync_image_count > SIZE_MAX / sizeof(copy[0])) return false; copy = calloc(s->sync_image_count, sizeof(copy[0])); if (!copy) return false; for (size_t i = 0; i < s->sync_image_count; i++) { - copy[i].token_start = s->sync_images[i].token_start; - copy[i].token_count = s->sync_images[i].embedding.token_count; + const ds4_vision_span *current = &s->sync_images[i]; + copy[i].token_start = current->token_start; + copy[i].token_count = current->embedding.token_count; memcpy(copy[i].fingerprint, - s->sync_images[i].embedding.fingerprint, + current->embedding.fingerprint, sizeof(copy[i].fingerprint)); + const uint64_t end = (uint64_t)copy[i].token_start + + copy[i].token_count; + if (retained_tokens >= 0 && end <= (uint64_t)retained_tokens && + i < s->checkpoint_image_count) { + /* Retained KV still belongs to the old conditioning vectors, + * even if this request carries newly encoded vectors/hash. */ + const ds4_vision_identity *previous = + &s->checkpoint_images[i]; + if (previous->token_start == copy[i].token_start && + previous->token_count == copy[i].token_count && + previous->state_fingerprint_valid && + memcmp(previous->fingerprint, copy[i].fingerprint, + sizeof(copy[i].fingerprint)) == 0) { + memcpy(copy[i].state_fingerprint, + previous->state_fingerprint, + sizeof(copy[i].state_fingerprint)); + copy[i].state_fingerprint_valid = true; + } + } else if (retained_tokens >= 0 && + copy[i].token_start >= (uint32_t)retained_tokens && + current->embedding.state_fingerprint_valid) { + memcpy(copy[i].state_fingerprint, + current->embedding.state_fingerprint, + sizeof(copy[i].state_fingerprint)); + copy[i].state_fingerprint_valid = true; + } } } free(s->checkpoint_images); @@ -66304,11 +66428,17 @@ static bool ds4_session_store_vision_identities(ds4_session *s) { * graph sequence and the per-layer gates pair up. The worker acks a sync * once its matching prefill completes, surfacing worker-side failures * here instead of as a gate timeout mid-decode. */ -int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, char *err, size_t errlen) { +static int ds4_session_sync_impl(ds4_session *s, const ds4_tokens *prompt, + char *err, size_t errlen, + bool *backend_started) { + if (backend_started) *backend_started = false; + int preflight = ds4_session_sync_preflight(s, prompt, err, errlen); + if (preflight != 0) return preflight; if (s && !ds4_session_vision_prefix_matches( s, s->sync_images, s->sync_image_count)) { ds4_session_invalidate(s); } + if (backend_started) *backend_started = true; #ifndef DS4_NO_GPU ds4_session_dspark_scheduler_begin_request(s); #endif @@ -66330,7 +66460,9 @@ int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, char *err, size_t return 1; } } - int rc = ds4_session_sync_internal(s, prompt, err, errlen); + int retained_tokens = 0; + int rc = ds4_session_sync_internal(s, prompt, err, errlen, + &retained_tokens); #ifndef DS4_NO_GPU if (rc == 0) glm_debug_dump_prefill_logits(s->logits); if (rc == 0) { @@ -66376,7 +66508,7 @@ int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, char *err, size_t return rc != 0 ? rc : 1; } } - if (rc == 0 && !ds4_session_store_vision_identities(s)) { + if (rc == 0 && !ds4_session_store_vision_identities(s, retained_tokens)) { ds4_session_invalidate(s); snprintf(err, errlen, "unable to retain image prompt identity"); return 1; @@ -66384,6 +66516,300 @@ int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, char *err, size_t return rc; } +int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, + char *err, size_t errlen) { + return ds4_session_sync_impl(s, prompt, err, errlen, NULL); +} + +static int ds4_session_finish_multimodal_sync(ds4_session *s, int rc, + bool backend_started) { + /* A failed image sync may have advanced the token checkpoint at a safe + * prefill boundary, but ds4_session_sync() publishes image identities only + * after the whole sync succeeds. Do not leave that image-conditioned KV + * frontier available for later eviction or reuse without an exact key. */ + if (rc != 0 && backend_started && s && + (s->checkpoint_valid || s->checkpoint_image_count != 0)) { + ds4_session_invalidate(s); + } + if (s) { + s->sync_images = NULL; + s->sync_image_count = 0; + } + return rc; +} + +#ifndef DS4_NO_GPU +static bool ds4_test_cancel_always(void *ud) { + (void)ud; + return true; +} + +bool ds4_test_multimodal_sync_finish_policy(void) { + ds4_engine engine = {0}; + engine.backend = DS4_BACKEND_CPU; + + for (size_t retained = 0; retained <= 1; retained++) { + ds4_session session = {0}; + ds4_vision_span images[2] = {0}; + session.engine = &engine; + session.checkpoint_valid = true; + token_vec_push(&session.checkpoint, 7); + if (retained != 0) { + session.checkpoint_images = calloc(1, sizeof(session.checkpoint_images[0])); + if (!session.checkpoint_images) { + token_vec_free(&session.checkpoint); + return false; + } + session.checkpoint_image_count = 1; + } + session.sync_images = images; + session.sync_image_count = retained + 1; + + int rc = ds4_session_finish_multimodal_sync( + &session, DS4_SESSION_SYNC_INTERRUPTED, true); + bool invalidated = rc == DS4_SESSION_SYNC_INTERRUPTED && + !session.checkpoint_valid && + session.checkpoint.len == 0 && + !session.checkpoint_images && + session.checkpoint_image_count == 0 && + !session.sync_images && + session.sync_image_count == 0; + token_vec_free(&session.checkpoint); + free(session.checkpoint_images); + if (!invalidated) return false; + } + + ds4_session completed = {0}; + ds4_vision_span image = {0}; + completed.engine = &engine; + completed.checkpoint_valid = true; + token_vec_push(&completed.checkpoint, 11); + completed.checkpoint_images = calloc(1, sizeof(completed.checkpoint_images[0])); + if (!completed.checkpoint_images) { + token_vec_free(&completed.checkpoint); + return false; + } + completed.checkpoint_image_count = 1; + completed.sync_images = ℑ + completed.sync_image_count = 1; + int rc = ds4_session_finish_multimodal_sync(&completed, 0, true); + bool preserved = rc == 0 && completed.checkpoint_valid && + completed.checkpoint.len == 1 && + completed.checkpoint_image_count == 1 && + !completed.sync_images && + completed.sync_image_count == 0; + token_vec_free(&completed.checkpoint); + free(completed.checkpoint_images); + if (!preserved) return false; + + ds4_session rejected = {0}; + ds4_vision_span rejected_image = {0}; + int rejected_token = 42; + ds4_tokens rejected_prompt = { + .v = &rejected_token, + .len = 1, + .cap = 1, + }; + engine.vision_ready = true; + engine.vision_image_token = rejected_token; + rejected.engine = &engine; + rejected.ctx_size = 1; + rejected.checkpoint_valid = true; + token_vec_push(&rejected.checkpoint, 17); + rejected.checkpoint_images = calloc(1, sizeof(rejected.checkpoint_images[0])); + if (!rejected.checkpoint_images) { + token_vec_free(&rejected.checkpoint); + return false; + } + rejected.checkpoint_image_count = 1; + rejected_image.embedding.data = (float *)&rejected_token; + rejected_image.embedding.token_count = 1; + char err[80] = {0}; + rc = ds4_session_sync_multimodal(&rejected, &rejected_prompt, + &rejected_image, 1, + err, sizeof(err)); + preserved = rc != 0 && rejected.checkpoint_valid && + rejected.checkpoint.len == 1 && + rejected.checkpoint_image_count == 1; + if (preserved) { + rejected.ctx_size = 2; + rejected.cancel = ds4_test_cancel_always; + rc = ds4_session_sync_multimodal(&rejected, &rejected_prompt, + &rejected_image, 1, + err, sizeof(err)); + preserved = rc == DS4_SESSION_SYNC_INTERRUPTED && + rejected.checkpoint_valid && + rejected.checkpoint.len == 1 && + rejected.checkpoint_image_count == 1; + } + token_vec_free(&rejected.checkpoint); + free(rejected.checkpoint_images); + return preserved; +} + +bool ds4_test_vision_identity_carries_exact_state_fingerprint(void) { + ds4_session session = {0}; + ds4_vision_span current = {0}; + session.checkpoint_images = calloc(1, sizeof(session.checkpoint_images[0])); + if (!session.checkpoint_images) return false; + session.checkpoint_image_count = 1; + session.checkpoint_images[0].token_start = 64; + session.checkpoint_images[0].token_count = 8; + memset(session.checkpoint_images[0].fingerprint, 0x31, + sizeof(session.checkpoint_images[0].fingerprint)); + memset(session.checkpoint_images[0].state_fingerprint, 0xa7, + sizeof(session.checkpoint_images[0].state_fingerprint)); + session.checkpoint_images[0].state_fingerprint_valid = true; + + current.token_start = 64; + current.embedding.token_count = 8; + memset(current.embedding.fingerprint, 0x31, + sizeof(current.embedding.fingerprint)); + current.embedding.state_fingerprint_valid = false; + session.sync_images = ¤t; + session.sync_image_count = 1; + if (!ds4_session_store_vision_identities(&session, 72)) { + free(session.checkpoint_images); + return false; + } + bool preserved = session.checkpoint_image_count == 1 && + session.checkpoint_images[0].state_fingerprint_valid; + for (size_t i = 0; preserved && + i < sizeof(session.checkpoint_images[0].state_fingerprint); + i++) { + preserved = session.checkpoint_images[0].state_fingerprint[i] == 0xa7; + } + + /* A changed image must never inherit the old conditioning hash. */ + current.embedding.fingerprint[0] ^= 0xff; + current.embedding.state_fingerprint_valid = false; + if (!ds4_session_store_vision_identities(&session, 72)) preserved = false; + const bool changed_rejected = session.checkpoint_image_count == 1 && + !session.checkpoint_images[0].state_fingerprint_valid; + free(session.checkpoint_images); + return preserved && changed_rejected; +} + +bool ds4_test_vision_identity_keeps_retained_hash(void) { + const struct { + int retained; + bool previous_valid, current_valid, changed_source, expected_valid; + uint8_t expected_hash; + } cases[] = { + {80, true, true, false, true, 0xa7}, /* new hash must not relabel old KV */ + {72, true, false, false, true, 0xa7}, /* exact image-end boundary */ + { 0, true, true, false, true, 0xb8}, /* full rebuild uses new vectors */ + { 0, true, false, false, false, 0}, /* rebuild cannot inherit old hash */ + {64, true, true, false, true, 0xb8}, /* image starts at recompute boundary */ + {68, true, true, false, false, 0}, /* mixed old/new image rows */ + {-1, true, true, false, false, 0}, /* backend cannot attest provenance */ + {80, false, true, false, false, 0}, /* unknown old vectors stay unknown */ + {80, true, true, true, false, 0}, /* no matching retained identity */ + }; + for (size_t n = 0; n < sizeof(cases) / sizeof(cases[0]); n++) { + ds4_session session = {0}; + ds4_vision_span current[2] = {0}; + session.checkpoint_images = calloc(1, sizeof(session.checkpoint_images[0])); + if (!session.checkpoint_images) return false; + session.checkpoint_image_count = 1; + session.checkpoint_images[0].token_start = 64; + session.checkpoint_images[0].token_count = 8; + memset(session.checkpoint_images[0].fingerprint, 0x31, 32); + memset(session.checkpoint_images[0].state_fingerprint, 0xa7, 32); + session.checkpoint_images[0].state_fingerprint_valid = cases[n].previous_valid; + current[0].token_start = 64; + current[0].embedding.token_count = 8; + memset(current[0].embedding.fingerprint, + cases[n].changed_source ? 0x32 : 0x31, 32); + memset(current[0].embedding.state_fingerprint, 0xb8, 32); + current[0].embedding.state_fingerprint_valid = cases[n].current_valid; + /* An appended image is newly computed, independently of the old one. */ + current[1].token_start = 96; + current[1].embedding.token_count = 8; + memset(current[1].embedding.state_fingerprint, 0xc9, 32); + current[1].embedding.state_fingerprint_valid = true; + session.sync_images = current; + session.sync_image_count = 2; + bool ok = ds4_session_store_vision_identities(&session, cases[n].retained); + ok = ok && session.checkpoint_image_count == 2 && + session.checkpoint_images[0].state_fingerprint_valid == cases[n].expected_valid && + session.checkpoint_images[1].state_fingerprint_valid == (cases[n].retained >= 0); + for (size_t i = 0; ok && i < 32; i++) { + if (cases[n].expected_valid) + ok = session.checkpoint_images[0].state_fingerprint[i] == cases[n].expected_hash; + if (cases[n].retained >= 0) + ok = ok && session.checkpoint_images[1].state_fingerprint[i] == 0xc9; + } + free(session.checkpoint_images); + if (!ok) return false; + } + return true; +} + +bool ds4_test_vision_prefix_compatibility(void) { + ds4_session session = {0}; + session.checkpoint_valid = true; + for (int i = 0; i < 10; i++) token_vec_push(&session.checkpoint, i + 1); + session.checkpoint_images = calloc(1, sizeof(session.checkpoint_images[0])); + if (!session.checkpoint_images) { + token_vec_free(&session.checkpoint); + return false; + } + session.checkpoint_image_count = 1; + session.checkpoint_images[0].token_start = 2; + session.checkpoint_images[0].token_count = 3; + memset(session.checkpoint_images[0].fingerprint, 0xa5, 32); + + ds4_vision_span images[3] = {0}; + images[0].token_start = 2; + images[0].embedding.token_count = 3; + memset(images[0].embedding.fingerprint, 0xa5, 32); + images[1].token_start = 10; + images[1].embedding.token_count = 2; + memset(images[1].embedding.fingerprint, 0x5a, 32); + + bool ok = ds4_session_vision_prefix_matches(&session, images, 1) && + ds4_session_vision_state_matches(&session, images, 1) && + ds4_session_vision_prefix_matches(&session, images, 2) && + !ds4_session_vision_state_matches(&session, images, 2); + + /* New conditioning may begin exactly at, but never before, live KV. */ + images[1].token_start = 9; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 2); + images[1].token_start = 10; + + /* Retained conditioning is positional and content-addressed. */ + images[0].token_start++; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 2); + images[0].token_start = 2; + images[0].embedding.fingerprint[0] ^= 0xff; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 2); + images[0].embedding.fingerprint[0] ^= 0xff; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 0); + + /* All incoming spans are validated, including newly appended spans. */ + images[2] = images[1]; + images[2].token_start = 11; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 3); + images[2].token_start = 12; + ok = ok && ds4_session_vision_prefix_matches(&session, images, 3); + images[2].embedding.token_count = 0; + ok = ok && !ds4_session_vision_prefix_matches(&session, images, 3); + + /* A text-only live checkpoint follows the same rule for its first image. */ + free(session.checkpoint_images); + session.checkpoint_images = NULL; + session.checkpoint_image_count = 0; + ds4_vision_span appended = images[1]; + ok = ok && ds4_session_vision_prefix_matches(&session, &appended, 1); + appended.token_start = 9; + ok = ok && !ds4_session_vision_prefix_matches(&session, &appended, 1); + + token_vec_free(&session.checkpoint); + return ok; +} +#endif + int ds4_session_sync_multimodal( ds4_session *s, const ds4_tokens *prompt, @@ -66438,36 +66864,24 @@ int ds4_session_sync_multimodal( s->graph.prefill_vision_spans = images; s->graph.prefill_vision_span_count = image_count; #endif - const int rc = ds4_session_sync(s, prompt, err, errlen); + bool backend_started = false; + const int rc = ds4_session_sync_impl(s, prompt, err, errlen, + &backend_started); #ifndef DS4_NO_GPU s->graph.prefill_vision_spans = NULL; s->graph.prefill_vision_span_count = 0; #endif - s->sync_images = NULL; - s->sync_image_count = 0; - return rc; + return ds4_session_finish_multimodal_sync(s, rc, backend_started); } -static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, char *err, size_t errlen) { - if (!s || !prompt) { - snprintf(err, errlen, "missing session or prompt"); - return 1; - } - if (prompt->len <= 0) { - snprintf(err, errlen, "empty prompt"); - return 1; - } - if (prompt->len >= s->ctx_size) { - snprintf(err, errlen, - "prompt length %d exceeds context %d (one token of generation room is required)", - prompt->len, s->ctx_size); - return 1; - } - if (ds4_session_cancelled(s)) { - snprintf(err, errlen, "interrupted"); - return DS4_SESSION_SYNC_INTERRUPTED; - } +static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, + char *err, size_t errlen, + int *retained_tokens) { + *retained_tokens = 0; if (s->distributed) { + /* The coordinator may recover by replaying after a suffix failure; + * its current interface does not report which image rows survived. */ + *retained_tokens = -1; const ds4_tokens *checkpoint = s->checkpoint_valid ? &s->checkpoint : NULL; return ds4_dist_session_sync(s->distributed, s, @@ -66483,6 +66897,7 @@ static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, c prompt->len >= s->checkpoint.len && ds4_tokens_starts_with(prompt, &s->checkpoint)) { + *retained_tokens = s->checkpoint.len; s->mtp_draft_valid = false; for (int i = s->checkpoint.len; i < prompt->len; i++) { if (ds4_session_cancelled(s)) { @@ -66571,6 +66986,7 @@ static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, c ds4_tokens_starts_with(prompt, &s->checkpoint)) { start = s->checkpoint.len; + *retained_tokens = start; resumed_checkpoint = true; s->mtp_draft_valid = false; } else { @@ -66805,6 +67221,9 @@ static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, c s->glm_graph.full_kv_cache && s->glm_dense_cache_len < s->glm_graph.ctx_cap) { + /* This repairs only a dense window alongside retained compact + * state. A single retained-prefix boundary cannot describe it. */ + *retained_tokens = -1; const uint32_t dense_end = (uint32_t)prompt->len < s->glm_graph.ctx_cap ? (uint32_t)prompt->len : @@ -66909,6 +67328,11 @@ static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, c (uint32_t)suffix >= resume_min && indexed_batch_available) { + /* A dense-cache gap is not a lost compact prefix. Indexed prefill + * reads layer_kv_lora_cache/layer_k_rope_cache and writes only from + * start onward. The decode-resume branch below uses the same + * compact caches when dense KV lags. Both retain the old image + * conditioning; only the dense bridge above reports unknown. */ for (int i = start; i < prompt->len; ) { if (ds4_session_cancelled(s)) { snprintf(err, errlen, "interrupted"); @@ -67276,6 +67700,7 @@ static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, c prompt->len >= s->checkpoint.len && ds4_tokens_starts_with(prompt, &s->checkpoint)) { + *retained_tokens = s->checkpoint.len; s->mtp_draft_valid = false; const int suffix = prompt->len - s->checkpoint.len; const uint32_t resume_min = metal_graph_resume_prefill_min_tokens(); diff --git a/ds4.h b/ds4.h index e6dae1b9f..04c0c3f85 100644 --- a/ds4.h +++ b/ds4.h @@ -184,7 +184,12 @@ typedef struct { uint32_t height; uint32_t content_width; uint32_t content_height; + /* Decoded source-image identity, used for live same-engine matching. */ uint8_t fingerprint[32]; + /* Exact vectors supplied to the language model, including DeepSeek's + * vision-sidecar sentinel vectors. Used for process-boundary cache keys. */ + uint8_t state_fingerprint[32]; + bool state_fingerprint_valid; } ds4_vision_embedding; typedef struct { @@ -428,13 +433,31 @@ int ds4_session_sync_multimodal(ds4_session *s, size_t image_count, char *err, size_t errlen); +/* Return true when every image already represented by the live checkpoint + * matches the supplied prompt and every additional image starts at or beyond + * the live token frontier. The caller must independently verify the token + * prefix. */ +bool ds4_session_vision_prefix_matches(const ds4_session *s, + const ds4_vision_span *images, + size_t image_count); /* Return true only when every image that conditioned the live checkpoint has * the same token span and embedding fingerprint in the supplied prompt. */ bool ds4_session_vision_state_matches(const ds4_session *s, const ds4_vision_span *images, size_t image_count); +/* Inspect the exact conditioning-state identities of the live checkpoint. */ +size_t ds4_session_vision_identity_count(const ds4_session *s); +bool ds4_session_vision_identity(const ds4_session *s, size_t index, + uint32_t *token_start, + uint32_t *token_count, + uint8_t state_fingerprint[32]); +/* Attach already-verified request identities after restoring a disk payload. + * Every image must end at or before the restored token frontier. */ +bool ds4_session_restore_vision_identities(ds4_session *s, + const ds4_vision_span *images, + size_t image_count); /* True while a session contains, or is actively syncing, image-conditioned - * state. Such state must not be written to the text-keyed disk KV cache. */ + * state. Such state needs an image-identity-aware disk cache key. */ bool ds4_session_has_vision_state(const ds4_session *s); bool ds4_session_rewrite_requires_rebuild(int live_len, int canonical_len, int common); ds4_session_rewrite_result ds4_session_rewrite_from_common( diff --git a/ds4_image.c b/ds4_image.c index 3d3ac2f8b..f40786809 100644 --- a/ds4_image.c +++ b/ds4_image.c @@ -126,6 +126,15 @@ static void ds4_sha256_final(ds4_sha256 *sha, uint8_t out[32]) { } } +void ds4_image_fingerprint_data(const void *data, size_t len, + uint8_t fingerprint[32]) { + if (!fingerprint) return; + ds4_sha256 sha; + ds4_sha256_init(&sha); + if (data && len != 0) ds4_sha256_update(&sha, data, len); + ds4_sha256_final(&sha, fingerprint); +} + static void ds4_image_error(char *error, size_t cap, const char *message) { if (!error || cap == 0) return; snprintf(error, cap, "%s", message); diff --git a/ds4_image.h b/ds4_image.h index 9d7a473ec..e400d4554 100644 --- a/ds4_image.h +++ b/ds4_image.h @@ -74,6 +74,11 @@ int ds4_image_decode_file( void ds4_image_free(ds4_image *image); +/* SHA-256 of an exact byte sequence. Used to bind cached multimodal state to + * the conditioning vectors that were actually supplied to the language model. */ +void ds4_image_fingerprint_data(const void *data, size_t len, + uint8_t fingerprint[32]); + int ds4_image_preprocess_glm53( ds4_image_patches *out, const ds4_image *image, diff --git a/ds4_kvstore.c b/ds4_kvstore.c index 5b73de7b6..10deb1741 100644 --- a/ds4_kvstore.c +++ b/ds4_kvstore.c @@ -183,6 +183,7 @@ uint8_t ds4_kvstore_reason_code(const char *reason) { } const char *ds4_kvstore_key_kind(uint8_t ext_flags) { + if (ext_flags & DS4_KVSTORE_EXT_VISION_IDENTITY) return "vision-token-text"; if (ext_flags & DS4_KVSTORE_EXT_RESPONSES_VISIBLE) return "responses-visible"; if (ext_flags & DS4_KVSTORE_EXT_THINKING_VISIBLE) return "thinking-visible"; return "token-text"; @@ -843,7 +844,8 @@ static bool kv_cache_file_text_matches(const char *path, const char sha[41], static bool kv_cache_existing_compatible(ds4_kvstore *kc, const char *path, const char sha[41], const char *text, size_t text_len, - int model_id, int quant_bits, int ctx_size) { + int model_id, int quant_bits, + int ctx_size, uint8_t ext_flags) { if (access(path, F_OK) != 0) return false; ds4_kvstore_entry e = {0}; if (!ds4_kvstore_read_entry_file(path, sha, &e)) return false; @@ -851,6 +853,8 @@ static bool kv_cache_existing_compatible(ds4_kvstore *kc, const char *path, (!kc->reject_different_quant || e.quant_bits == (uint8_t)quant_bits) && e.ctx_size <= (uint32_t)ctx_size && + ((e.ext_flags ^ ext_flags) & + DS4_KVSTORE_EXT_VISION_IDENTITY) == 0 && kv_cache_file_text_matches(path, sha, text, text_len); ds4_kvstore_entry_free(&e); if (!compatible) { @@ -993,10 +997,13 @@ bool ds4_kvstore_store_live_prefix_text(ds4_kvstore *kc, ds4_kvstore_sha1_bytes_hex(text, text_len, sha); char *path = ds4_kvstore_path_for_sha(kc, sha); const uint8_t reason_code = ds4_kvstore_reason_code(reason); + uint8_t ext_flags = trailer_est_bytes > 0 && hooks ? hooks->ext_flag : 0; + if (text_override) ext_flags |= cache_text_ext; if (kv_cache_existing_compatible(kc, path, sha, text, text_len, model_id, - quant_bits, ds4_session_ctx(session))) { + quant_bits, ds4_session_ctx(session), + ext_flags)) { kv_cache_rewrite_trailer(kc, path, text, hooks); free(text); free(path); @@ -1071,8 +1078,6 @@ bool ds4_kvstore_store_live_prefix_text(ds4_kvstore *kc, const uint64_t now = (uint64_t)time(NULL); uint8_t h[DS4_KVSTORE_FIXED_HEADER]; - uint8_t ext_flags = trailer_est_bytes > 0 && hooks ? hooks->ext_flag : 0; - if (text_override) ext_flags |= cache_text_ext; ds4_kvstore_fill_header(h, (uint8_t)model_id, (uint8_t)quant_bits, reason_code, ext_flags, (uint32_t)store_tokens.len, 0, @@ -1187,49 +1192,118 @@ bool ds4_kvstore_maybe_store_continued(ds4_kvstore *kc, return false; } -int ds4_kvstore_find_text_prefix(ds4_kvstore *kc, const char *prompt_text, - int model_id, int quant_bits, int ctx_size) { - if (!prompt_text) return -1; - const size_t prompt_bytes = strlen(prompt_text); +int ds4_kvstore_find_text_prefix_filtered(ds4_kvstore *kc, + const char *prompt_text, + int model_id, int quant_bits, + int ctx_size, + uint8_t required_ext_flags, + uint8_t forbidden_ext_flags) { + ds4_kvstore_prefix_query query = { + .text = prompt_text, + .max_tokens = UINT32_MAX, + .max_key_bytes = UINT32_MAX, + .required_ext_flags = required_ext_flags, + .forbidden_ext_flags = forbidden_ext_flags, + }; + return ds4_kvstore_find_best_prefix( + kc, &query, 1, model_id, quant_bits, ctx_size, NULL); +} + +int ds4_kvstore_find_best_prefix( + ds4_kvstore *kc, + const ds4_kvstore_prefix_query *queries, + size_t query_count, + int model_id, int quant_bits, int ctx_size, + size_t *matched_query_out) { + if (matched_query_out) *matched_query_out = SIZE_MAX; + if (!kc || !queries || query_count == 0) return -1; + kv_cache_refresh(kc); int best = -1; - for (int i = 0; i < kc->len; i++) { - ds4_kvstore_entry *e = &kc->entry[i]; - if (e->text_bytes > prompt_bytes || e->text_bytes > SIZE_MAX) continue; - if ((int)e->tokens < kc->opt.min_tokens) continue; - if (e->model_id != (uint8_t)model_id) continue; - if ((uint32_t)ctx_size < e->ctx_size) continue; - if (kc->reject_different_quant && e->quant_bits != (uint8_t)quant_bits) continue; - if (best >= 0) { - ds4_kvstore_entry *b = &kc->entry[best]; - if (e->text_bytes < b->text_bytes) continue; - if (e->text_bytes == b->text_bytes && e->tokens <= b->tokens) continue; + size_t best_query = SIZE_MAX; + for (size_t q = 0; q < query_count; q++) { + const ds4_kvstore_prefix_query *query = &queries[q]; + if (!query->text) continue; + const size_t prompt_bytes = strlen(query->text); + int query_best = -1; + for (int i = 0; i < kc->len; i++) { + ds4_kvstore_entry *e = &kc->entry[i]; + if ((int)e->tokens < kc->opt.min_tokens) continue; + if (e->model_id != (uint8_t)model_id) continue; + if ((uint32_t)ctx_size < e->ctx_size) continue; + if (kc->reject_different_quant && + e->quant_bits != (uint8_t)quant_bits) continue; + if (e->tokens > query->max_tokens) continue; + if (e->text_bytes > query->max_key_bytes) continue; + if ((e->ext_flags & query->required_ext_flags) != + query->required_ext_flags || + (e->ext_flags & query->forbidden_ext_flags) != 0) continue; + if (e->text_bytes > prompt_bytes || e->text_bytes > SIZE_MAX) + continue; + + /* Preserve the established text-cache ordering within one key + * spelling: consume the longest rendered byte prefix, then the + * largest exact token history for an equal byte prefix. */ + if (query_best >= 0) { + const ds4_kvstore_entry *b = &kc->entry[query_best]; + if (e->text_bytes < b->text_bytes) continue; + if (e->text_bytes == b->text_bytes && + e->tokens <= b->tokens) continue; + } + char sha[41]; + ds4_kvstore_sha1_bytes_hex(query->text, + (size_t)e->text_bytes, sha); + if (strcmp(sha, e->sha)) continue; + query_best = i; } - char sha[41]; - ds4_kvstore_sha1_bytes_hex(prompt_text, (size_t)e->text_bytes, sha); - if (!strcmp(sha, e->sha)) best = i; + + if (query_best < 0) continue; + /* Metadata makes byte lengths incomparable across conditioning + * spellings. The caller orders them by semantic frontier (for example, + * all matching images before fewer matching images), so the first + * spelling with a candidate wins. */ + best = query_best; + best_query = q; + break; } + if (best >= 0 && matched_query_out) *matched_query_out = best_query; return best; } -int ds4_kvstore_try_load_text(ds4_kvstore *kc, - ds4_engine *engine, - ds4_session *session, - const char *prompt_text, - ds4_tokens *effective_prompt, - ds4_kvstore_load_result *result, - const ds4_kvstore_trailer_hooks *hooks, - bool responses_protocol) { +int ds4_kvstore_find_text_prefix(ds4_kvstore *kc, const char *prompt_text, + int model_id, int quant_bits, int ctx_size) { + return ds4_kvstore_find_text_prefix_filtered( + kc, prompt_text, model_id, quant_bits, ctx_size, 0, + DS4_KVSTORE_EXT_VISION_IDENTITY); +} + +int ds4_kvstore_try_load_best_prefix( + ds4_kvstore *kc, + ds4_engine *engine, + ds4_session *session, + const ds4_kvstore_prefix_query *queries, + size_t query_count, + ds4_tokens *effective_prompt, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol, + size_t *matched_query_out) { if (result) memset(result, 0, sizeof(*result)); + if (matched_query_out) *matched_query_out = SIZE_MAX; if (effective_prompt) effective_prompt->len = 0; - if (!kc->enabled || !prompt_text) return 0; + if (!kc->enabled || !queries || query_count == 0) return 0; const int quant_bits = ds4_engine_routed_quant_bits(engine); if (quant_bits != 2 && quant_bits != 4) return 0; const int model_id = ds4_engine_model_id(engine); + size_t matched_query = SIZE_MAX; + int idx = ds4_kvstore_find_best_prefix( + kc, queries, query_count, model_id, quant_bits, + ds4_session_ctx(session), &matched_query); + if (idx < 0 || matched_query >= query_count) return 0; + + const ds4_kvstore_prefix_query *query = &queries[matched_query]; + const char *prompt_text = query->text; const size_t prompt_bytes = strlen(prompt_text); - int idx = ds4_kvstore_find_text_prefix(kc, prompt_text, model_id, quant_bits, - ds4_session_ctx(session)); - if (idx < 0) return 0; ds4_kvstore_entry e = kc->entry[idx]; char *path = kv_xstrdup(e.path); @@ -1248,6 +1322,17 @@ int ds4_kvstore_try_load_text(ds4_kvstore *kc, if (hdr.model_id != (uint8_t)model_id) { header_ok = false; fail_reason = "cached checkpoint was written for a different model"; + } else if ((hdr.ext_flags & query->required_ext_flags) != + query->required_ext_flags || + (hdr.ext_flags & query->forbidden_ext_flags) != 0) { + header_ok = false; + fail_reason = "cached checkpoint has the wrong key kind"; + } else if (hdr.tokens > query->max_tokens) { + header_ok = false; + fail_reason = "cached checkpoint crosses the conditioning frontier"; + } else if (text_bytes > query->max_key_bytes) { + header_ok = false; + fail_reason = "cached key crosses the conditioning frontier"; } else if ((uint64_t)text_bytes > prompt_bytes) { header_ok = false; fail_reason = "cached text is longer than prompt"; @@ -1333,12 +1418,49 @@ int ds4_kvstore_try_load_text(ds4_kvstore *kc, result->load_ms = load_ms; result->path = kv_xstrdup(path); } + if (matched_query_out) *matched_query_out = matched_query; } free(cached_text); free(path); return loaded; } +int ds4_kvstore_try_load_text_filtered( + ds4_kvstore *kc, + ds4_engine *engine, + ds4_session *session, + const char *prompt_text, + ds4_tokens *effective_prompt, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol, + uint8_t required_ext_flags, + uint8_t forbidden_ext_flags) { + ds4_kvstore_prefix_query query = { + .text = prompt_text, + .max_tokens = UINT32_MAX, + .max_key_bytes = UINT32_MAX, + .required_ext_flags = required_ext_flags, + .forbidden_ext_flags = forbidden_ext_flags, + }; + return ds4_kvstore_try_load_best_prefix( + kc, engine, session, &query, 1, effective_prompt, result, hooks, + responses_protocol, NULL); +} + +int ds4_kvstore_try_load_text(ds4_kvstore *kc, + ds4_engine *engine, + ds4_session *session, + const char *prompt_text, + ds4_tokens *effective_prompt, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol) { + return ds4_kvstore_try_load_text_filtered( + kc, engine, session, prompt_text, effective_prompt, result, hooks, + responses_protocol, 0, DS4_KVSTORE_EXT_VISION_IDENTITY); +} + void ds4_kvstore_load_result_free(ds4_kvstore_load_result *result) { if (!result) return; free(result->path); diff --git a/ds4_kvstore.h b/ds4_kvstore.h index 28ccdb7ea..6136b6370 100644 --- a/ds4_kvstore.h +++ b/ds4_kvstore.h @@ -16,6 +16,7 @@ #define DS4_KVSTORE_EXT_RESPONSES_VISIBLE (1u << 1) #define DS4_KVSTORE_EXT_THINKING_VISIBLE (1u << 2) #define DS4_KVSTORE_EXT_SESSION_TITLE (1u << 3) +#define DS4_KVSTORE_EXT_VISION_IDENTITY (1u << 4) typedef enum { DS4_KVSTORE_REASON_UNKNOWN = 0, @@ -34,10 +35,10 @@ typedef enum { } ds4_kvstore_log_type; typedef struct { - /* The file name is the rendered byte prefix, not the token sequence. The - * payload still carries the exact tokens and graph state; the hash only - * answers "does this checkpoint represent the bytes at the front of the - * incoming prompt?" */ + /* The file name is the cache-key byte prefix, not the token sequence. This + * is normally rendered text; callers may prepend identity metadata for + * state, such as images, that text alone cannot distinguish. The payload + * still carries the exact tokens and graph state. */ char sha[41]; char *path; uint8_t quant_bits; @@ -106,6 +107,22 @@ typedef struct { char *path; } ds4_kvstore_load_result; +/* One compatible-prefix spelling for a cache lookup. Callers order queries + * from most to least preferred. Multimodal callers may provide several + * spellings for the same prompt: zero conditioning spans selects a legacy text + * checkpoint, while progressively longer conditioning prefixes select + * checkpoints containing those exact image identities. + * + * The bounds prevent a candidate frontier from crossing the next unmatched + * conditioning span in either exact-token or rendered-key space. */ +typedef struct { + const char *text; + uint32_t max_tokens; + uint32_t max_key_bytes; + uint8_t required_ext_flags; + uint8_t forbidden_ext_flags; +} ds4_kvstore_prefix_query; + ds4_kvstore_options ds4_kvstore_default_options(void); uint8_t ds4_kvstore_reason_code(const char *reason); const char *ds4_kvstore_key_kind(uint8_t ext_flags); @@ -158,6 +175,18 @@ void ds4_kvstore_evict(ds4_kvstore *kc, const ds4_tokens *live, const ds4_kvstore_eviction_context *incoming); int ds4_kvstore_find_text_prefix(ds4_kvstore *kc, const char *prompt_text, int model_id, int quant_bits, int ctx_size); +int ds4_kvstore_find_text_prefix_filtered(ds4_kvstore *kc, + const char *prompt_text, + int model_id, int quant_bits, + int ctx_size, + uint8_t required_ext_flags, + uint8_t forbidden_ext_flags); +int ds4_kvstore_find_best_prefix( + ds4_kvstore *kc, + const ds4_kvstore_prefix_query *queries, + size_t query_count, + int model_id, int quant_bits, int ctx_size, + size_t *matched_query_out); bool ds4_kvstore_store_live_prefix_text(ds4_kvstore *kc, ds4_engine *engine, @@ -194,6 +223,28 @@ int ds4_kvstore_try_load_text(ds4_kvstore *kc, ds4_kvstore_load_result *result, const ds4_kvstore_trailer_hooks *hooks, bool responses_protocol); +int ds4_kvstore_try_load_text_filtered( + ds4_kvstore *kc, + ds4_engine *engine, + ds4_session *session, + const char *prompt_text, + ds4_tokens *effective_prompt, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol, + uint8_t required_ext_flags, + uint8_t forbidden_ext_flags); +int ds4_kvstore_try_load_best_prefix( + ds4_kvstore *kc, + ds4_engine *engine, + ds4_session *session, + const ds4_kvstore_prefix_query *queries, + size_t query_count, + ds4_tokens *effective_prompt, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol, + size_t *matched_query_out); void ds4_kvstore_load_result_free(ds4_kvstore_load_result *result); bool ds4_kvstore_read_header(FILE *fp, ds4_kvstore_entry *e, diff --git a/ds4_server.c b/ds4_server.c index 50efbe6bc..e4767f692 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -2,6 +2,7 @@ #include "ds4_distributed.h" #include "ds4_gpu_args.h" #include "ds4_help.h" +#include "ds4_image.h" #include "ds4_kvstore.h" #include "ds4_tp.h" #include "rax.h" @@ -9683,9 +9684,9 @@ static void apply_anthropic_stream_tool_ids(tool_calls *calls, * The server has one live Metal session. We persist reusable DS4 session * snapshots when a cold prompt reaches a useful prefix, when a long continued * conversation has grown far enough, and when a request evicts the live session. - * The cache key is the SHA1 of the rendered byte prefix. The payload still - * stores exact token IDs and graph state; the filename only selects a checkpoint - * whose decoded transcript bytes are a prefix of the next rendered request. + * The cache filename is the SHA1 of a lookup-key prefix. Text keys use the + * rendered bytes directly; conditioned keys prepend span identities. The + * payload still stores exact token IDs and graph state. * * Files are loaded with plain read/write I/O into the existing graph tensors; * mmap is deliberately avoided here so cache restore cannot add more VM @@ -9706,11 +9707,15 @@ static void apply_anthropic_stream_tool_ids(tool_calls *calls, * DS4 engine payload written by ds4_session_save_payload() * optional tool-id map section * - * The filename is SHA1(cache text bytes), not SHA1(token ids). For ordinary - * checkpoints the cache text is the rendered token prefix. For live hidden - * state it can instead be the client-visible transcript: the payload still - * contains sampled reasoning KV, but the lookup key must be what the client can - * replay after a process restart or session switch. + * The filename is SHA1(cache key bytes), not SHA1(token ids). For ordinary + * checkpoints the key is the rendered token prefix. For live hidden state it + * can instead be the client-visible transcript. Image-conditioned checkpoints + * prepend exact image spans and hashes of the actual conditioning vectors to + * the rendered tokens, so identical placeholder tokens produced by different + * images or encoder weights cannot collide. The lookup considers all retained + * image-prefix spellings together with ordinary text, then selects the longest + * safe token frontier. The payload always contains the + * exact token and graph state being restored. * * The optional tool-id map is not part of model state, but it is needed to * render future client JSON back to the exact DSML sampled by the model. We @@ -9722,6 +9727,8 @@ static void apply_anthropic_stream_tool_ids(tool_calls *calls, #define KV_EXT_TOOL_MAP DS4_KVSTORE_EXT_TOOL_MAP #define KV_EXT_RESPONSES_VISIBLE DS4_KVSTORE_EXT_RESPONSES_VISIBLE #define KV_EXT_THINKING_VISIBLE DS4_KVSTORE_EXT_THINKING_VISIBLE +#define KV_EXT_SESSION_TITLE DS4_KVSTORE_EXT_SESSION_TITLE +#define KV_EXT_VISION_IDENTITY DS4_KVSTORE_EXT_VISION_IDENTITY #define KV_TOOL_MAP_MAGIC0 'K' #define KV_TOOL_MAP_MAGIC1 'T' #define KV_TOOL_MAP_MAGIC2 'M' @@ -10067,6 +10074,102 @@ static char *render_tokens_text(ds4_engine *engine, const ds4_tokens *tokens, si return ds4_kvstore_render_tokens_text(engine, tokens, out_len); } +static void vision_cache_key_header(buf *key, size_t image_count) { + buf_puts(key, "\036DS4_VISION_KV_V2\n"); + buf_printf(key, "%zu\n", image_count); +} + +static void vision_cache_key_identity(buf *key, uint32_t token_start, + uint32_t token_count, + const uint8_t fingerprint[32]) { + static const char hex[] = "0123456789abcdef"; + buf_printf(key, "%08x%08x", token_start, token_count); + for (size_t i = 0; i < 32; i++) { + char encoded[2] = { + hex[fingerprint[i] >> 4], + hex[fingerprint[i] & 15], + }; + buf_append(key, encoded, sizeof(encoded)); + } + buf_puts(key, "\n"); +} + +static bool vision_embedding_fingerprint_state( + ds4_engine *engine, ds4_vision_embedding *embedding) { + if (!embedding) return false; + embedding->state_fingerprint_valid = false; + const int embd = ds4_engine_embd_dim(engine); + if (!embedding->data || embedding->token_count == 0 || embd <= 0) + return false; + const uint64_t values = + (uint64_t)embedding->token_count * (uint64_t)embd; + if (values > SIZE_MAX / sizeof(embedding->data[0])) return false; + ds4_image_fingerprint_data( + embedding->data, (size_t)values * sizeof(embedding->data[0]), + embedding->state_fingerprint); + embedding->state_fingerprint_valid = true; + return true; +} + +static char *vision_cache_key_from_text(const char *text, size_t text_len, + const ds4_vision_span *images, + size_t image_count) { + if (!text || !images || image_count == 0) return NULL; + buf key = {0}; + vision_cache_key_header(&key, image_count); + for (size_t i = 0; i < image_count; i++) { + if (!images[i].embedding.state_fingerprint_valid) { + buf_free(&key); + return NULL; + } + vision_cache_key_identity(&key, images[i].token_start, + images[i].embedding.token_count, + images[i].embedding.state_fingerprint); + } + buf_puts(&key, "\037"); + buf_append(&key, text, text_len); + return buf_take(&key); +} + +static char *vision_cache_key_for_session(server *s, server_slot *slot, + const ds4_tokens *tokens) { + if (!s || !slot || !tokens) return NULL; + pthread_mutex_lock(&s->inference_mu); + const size_t image_count = + ds4_session_vision_identity_count(slot->session); + if (image_count == 0) { + pthread_mutex_unlock(&s->inference_mu); + return NULL; + } + + size_t text_len = 0; + char *text = render_tokens_text(s->engine, tokens, &text_len); + buf key = {0}; + vision_cache_key_header(&key, image_count); + bool valid = true; + for (size_t i = 0; i < image_count; i++) { + uint32_t token_start = 0, token_count = 0; + uint8_t fingerprint[32]; + if (!ds4_session_vision_identity(slot->session, i, &token_start, + &token_count, fingerprint) || + (uint64_t)token_start + token_count > (uint64_t)tokens->len) { + valid = false; + break; + } + vision_cache_key_identity(&key, token_start, token_count, fingerprint); + } + pthread_mutex_unlock(&s->inference_mu); + if (!valid) { + buf_free(&key); + free(text); + return NULL; + } + buf_puts(&key, "\037"); + buf_append(&key, text, text_len); + free(text); + return buf_take(&key); +} + static bool byte_prefix_match(const char *text, size_t text_len, const char *prefix, size_t prefix_len) { return ds4_kvstore_byte_prefix_match(text, text_len, prefix, prefix_len); @@ -10162,12 +10265,12 @@ static bool kv_cache_store_live_prefix_text(server *s, server_slot *slot, char err[160] = {0}; ds4_kvstore_trailer_hooks hooks = kv_cache_tool_map_hooks(s, NULL); pthread_mutex_lock(&s->inference_mu); - /* The payload contains image-conditioned KV rows, but the disk key and - * trailer do not contain image fingerprints. Never let generic image - * placeholder tokens become a cache hit for a different image. - * sync_image_count covers progress-callback writes during prefill; - * checkpoint_image_count covers completed sessions. */ - if (ds4_session_has_vision_state(slot->session)) { + const bool vision_state = ds4_session_has_vision_state(slot->session); + const bool vision_key = (cache_text_ext & KV_EXT_VISION_IDENTITY) != 0; + /* Image-conditioned rows may only be written under a key containing their + * exact span and fingerprint identities. Conversely, never label an + * ordinary text payload as vision-conditioned. */ + if (vision_state != vision_key) { pthread_mutex_unlock(&s->inference_mu); return false; } @@ -10197,6 +10300,18 @@ static void kv_cache_store_current(server *s, server_slot *slot, const ds4_tokens *tokens = ds4_session_tokens(slot->session); if (!tokens) return; + if (ds4_session_has_vision_state(slot->session)) { + char *vision_key = vision_cache_key_for_session(s, slot, tokens); + if (vision_key) { + kv_cache_store_live_prefix_text(s, slot, tokens, tokens->len, + reason, vision_key, + KV_EXT_VISION_IDENTITY, + "vision-token-text"); + free(vision_key); + } + return; + } + char *visible_text = NULL; uint8_t visible_ext = 0; const char *visible_key = NULL; @@ -10315,40 +10430,91 @@ static int kv_cache_find_text_prefix(kv_disk_cache *kc, const char *prompt_text, int quant_bits, int ctx_size) { return ds4_kvstore_find_text_prefix(kc, prompt_text, 0, quant_bits, ctx_size); } + +static int kv_cache_find_text_prefix_filtered( + kv_disk_cache *kc, const char *prompt_text, + int quant_bits, int ctx_size, + uint8_t required_ext_flags, uint8_t forbidden_ext_flags) { + return ds4_kvstore_find_text_prefix_filtered( + kc, prompt_text, 0, quant_bits, ctx_size, + required_ext_flags, forbidden_ext_flags); +} #endif -static int kv_cache_try_load_text(server *s, server_slot *slot, - const char *prompt_text, +static int kv_cache_try_load_best_prefix( + server *s, server_slot *slot, + const ds4_kvstore_prefix_query *queries, + size_t query_count, ds4_tokens *effective_prompt, char **loaded_path_out, uint8_t *loaded_ext_flags_out, - bool responses_protocol) { + uint32_t *loaded_key_bytes_out, + bool responses_protocol, + size_t *matched_query_out) { if (!s || !slot) return 0; if (loaded_path_out) *loaded_path_out = NULL; if (loaded_ext_flags_out) *loaded_ext_flags_out = 0; + if (loaded_key_bytes_out) *loaded_key_bytes_out = 0; + if (matched_query_out) *matched_query_out = SIZE_MAX; ds4_kvstore_load_result lr = {0}; ds4_kvstore_trailer_hooks hooks = kv_cache_tool_map_hooks(s, NULL); pthread_mutex_lock(&s->inference_mu); - /* Disk payloads intentionally carry no image identity. If this slot held - * vision state, discard it before restoring a text-only checkpoint so the - * next sync does not reject the fresh payload as a stale image match. */ + /* A payload restore replaces the graph state. Clear identity from the + * slot's prior owner first; the vision caller reattaches the request's + * verified identities only after an image-keyed payload loads. */ if (ds4_session_has_vision_state(slot->session)) { ds4_session_invalidate(slot->session); } pthread_mutex_lock(&s->kv_mu); - int loaded = ds4_kvstore_try_load_text(&s->kv, s->engine, slot->session, - prompt_text, effective_prompt, &lr, - &hooks, responses_protocol); + size_t matched_query = SIZE_MAX; + int loaded = ds4_kvstore_try_load_best_prefix( + &s->kv, s->engine, slot->session, queries, query_count, + effective_prompt, &lr, &hooks, responses_protocol, &matched_query); pthread_mutex_unlock(&s->kv_mu); pthread_mutex_unlock(&s->inference_mu); if (loaded > 0) { if (loaded_path_out && lr.path) *loaded_path_out = xstrdup(lr.path); if (loaded_ext_flags_out) *loaded_ext_flags_out = lr.ext_flags; + if (loaded_key_bytes_out) *loaded_key_bytes_out = lr.text_bytes; + if (matched_query_out) *matched_query_out = matched_query; } ds4_kvstore_load_result_free(&lr); return loaded; } +static int kv_cache_try_load_text_filtered( + server *s, server_slot *slot, + const char *prompt_text, + ds4_tokens *effective_prompt, + char **loaded_path_out, + uint8_t *loaded_ext_flags_out, + bool responses_protocol, + uint8_t required_ext_flags, + uint8_t forbidden_ext_flags) { + ds4_kvstore_prefix_query query = { + .text = prompt_text, + .max_tokens = UINT32_MAX, + .max_key_bytes = UINT32_MAX, + .required_ext_flags = required_ext_flags, + .forbidden_ext_flags = forbidden_ext_flags, + }; + return kv_cache_try_load_best_prefix( + s, slot, &query, 1, effective_prompt, loaded_path_out, + loaded_ext_flags_out, NULL, responses_protocol, NULL); +} + +static int kv_cache_try_load_text(server *s, server_slot *slot, + const char *prompt_text, + ds4_tokens *effective_prompt, + char **loaded_path_out, + uint8_t *loaded_ext_flags_out, + bool responses_protocol) { + return kv_cache_try_load_text_filtered( + s, slot, prompt_text, effective_prompt, loaded_path_out, + loaded_ext_flags_out, responses_protocol, 0, + KV_EXT_VISION_IDENTITY); +} + static int kv_cache_try_load(server *s, server_slot *slot, const request *req, ds4_tokens *effective_prompt, char **loaded_path_out, @@ -10360,6 +10526,330 @@ static int kv_cache_try_load(server *s, server_slot *slot, const request *req, req && req->api == API_RESPONSES); } +static void kv_cache_conditioning_queries_free( + ds4_kvstore_prefix_query *queries, size_t query_count) { + if (!queries) return; + for (size_t i = 0; i < query_count; i++) { + free((char *)queries[i].text); + } + free(queries); +} + +/* Build one lookup spelling for every compatible conditioning prefix. Query + * zero images is the ordinary token-text key. Query N images uses the V2 + * image key from #961, but caps its rendered bytes at image N+1. A byte bound + * is required here because replaying sampled text can change BPE token counts; + * the exact token history is reconciled after restore. */ +static ds4_kvstore_prefix_query *kv_cache_conditioning_queries( + server *s, request *req, size_t *query_count_out) { + if (query_count_out) *query_count_out = 0; + if (!s || !req || req->image_count == 0 || !req->images || + req->prompt.len <= 0) return NULL; + + size_t text_len = 0; + char *text = render_tokens_text(s->engine, &req->prompt, &text_len); + if (!text) return NULL; + + size_t fingerprinted = 0; + while (fingerprinted < req->image_count) { + ds4_vision_embedding *embedding = + &req->images[fingerprinted].embedding; + if (!embedding->state_fingerprint_valid && + !vision_embedding_fingerprint_state(s->engine, embedding)) break; + fingerprinted++; + } + + if (req->image_count == SIZE_MAX || + req->image_count + 1 > SIZE_MAX / sizeof(ds4_kvstore_prefix_query)) { + free(text); + return NULL; + } + const size_t query_count = req->image_count + 1; + ds4_kvstore_prefix_query *queries = + xmalloc(query_count * sizeof(queries[0])); + memset(queries, 0, query_count * sizeof(queries[0])); + for (size_t retained = 0; retained <= req->image_count; retained++) { + /* Queries are ordered from most to least conditioning. */ + const size_t q = req->image_count - retained; + if (retained > fingerprinted) continue; + queries[q].text = retained == 0 ? xstrdup(text) : + vision_cache_key_from_text(text, text_len, req->images, retained); + if (!queries[q].text) continue; + + const size_t key_len = strlen(queries[q].text); + const size_t identity_bytes = key_len >= text_len ? + key_len - text_len : SIZE_MAX; + size_t frontier_text_bytes = text_len; + if (retained < req->image_count) { + const uint32_t token_start = req->images[retained].token_start; + if (token_start > (uint32_t)req->prompt.len) { + free((char *)queries[q].text); + queries[q].text = NULL; + continue; + } + ds4_tokens prefix = req->prompt; + prefix.len = (int)token_start; + char *prefix_text = render_tokens_text( + s->engine, &prefix, &frontier_text_bytes); + if (!prefix_text) { + free((char *)queries[q].text); + queries[q].text = NULL; + continue; + } + free(prefix_text); + } + if (identity_bytes > UINT32_MAX || + frontier_text_bytes > UINT32_MAX - identity_bytes) { + free((char *)queries[q].text); + queries[q].text = NULL; + continue; + } + queries[q].max_tokens = UINT32_MAX; + queries[q].max_key_bytes = + (uint32_t)(identity_bytes + frontier_text_bytes); + queries[q].required_ext_flags = retained == 0 ? 0 : + KV_EXT_VISION_IDENTITY; + queries[q].forbidden_ext_flags = retained == 0 ? + (KV_EXT_VISION_IDENTITY | KV_EXT_RESPONSES_VISIBLE | + KV_EXT_THINKING_VISIBLE | KV_EXT_SESSION_TITLE) : 0; + } + free(text); + if (query_count_out) *query_count_out = query_count; + return queries; +} + +static bool kv_cache_loaded_conditioning_matches( + const ds4_tokens *cached, const ds4_tokens *prompt, + const ds4_vision_span *images, size_t image_count, + size_t retained_images, const char **reason_out) { + const char *reason = "invalid-frontier"; + if (!cached || !prompt || !images || retained_images > image_count || + cached->len <= 0) goto fail; + + uint64_t previous_end = 0; + for (size_t i = 0; i < image_count; i++) { + const uint64_t start = images[i].token_start; + const uint64_t count = images[i].embedding.token_count; + const uint64_t end = start + count; + if (count == 0 || start < previous_end || end > (uint64_t)prompt->len) { + reason = "invalid-conditioning-span"; + goto fail; + } + if (i < retained_images) { + if (!images[i].embedding.state_fingerprint_valid || + end > (uint64_t)cached->len) { + reason = "retained-conditioning-crosses-frontier"; + goto fail; + } + } + previous_end = end; + } + if (reason_out) *reason_out = "compatible"; + return true; + +fail: + if (reason_out) *reason_out = reason; + return false; +} + +static size_t kv_cache_conditioning_key_prefix_bytes( + const ds4_kvstore_prefix_query *query, size_t retained_images) { + if (!query || !query->text) return SIZE_MAX; + if (retained_images == 0) return 0; + const char *separator = strchr(query->text, '\037'); + return separator ? (size_t)(separator - query->text) + 1 : SIZE_MAX; +} + +static bool kv_cache_shift_conditioning_spans( + ds4_vision_span *images, size_t image_count, size_t retained_images, + int64_t delta, int prompt_len) { + if (!images || retained_images > image_count || prompt_len < 0) return false; + uint64_t previous_end = 0; + for (size_t i = 0; i < image_count; i++) { + int64_t shifted = images[i].token_start; + if (i >= retained_images) shifted += delta; + if (shifted < 0 || shifted > UINT32_MAX) return false; + const uint64_t end = + (uint64_t)shifted + images[i].embedding.token_count; + if (images[i].embedding.token_count == 0 || + (uint64_t)shifted < previous_end || + end > (uint64_t)prompt_len) return false; + previous_end = end; + } + for (size_t i = retained_images; i < image_count; i++) { + images[i].token_start = + (uint32_t)((int64_t)images[i].token_start + delta); + } + return true; +} + +/* Reconcile the restored checkpoint's exact sampled tokenization with the + * canonical multimodal request. Text up to the next image is retokenized from + * the cached rendered-byte boundary; the image block and everything after it + * are then copied from the canonical request and shifted onto that exact + * history. */ +static bool kv_cache_rebuild_partial_conditioning_prompt( + server *s, server_slot *slot, request *req, + const ds4_kvstore_prefix_query *query, + size_t retained_images, uint32_t loaded_key_bytes, + ds4_tokens *effective_prompt, const char **reason_out) { + const char *reason = "invalid-conditioning-replay"; + if (!s || !slot || !req || !query || !effective_prompt || + retained_images >= req->image_count) goto fail; + + const size_t identity_bytes = + kv_cache_conditioning_key_prefix_bytes(query, retained_images); + if (identity_bytes == SIZE_MAX || loaded_key_bytes < identity_bytes) { + reason = "invalid-conditioning-key"; + goto fail; + } + const size_t cached_text_bytes = loaded_key_bytes - identity_bytes; + const uint32_t canonical_image_start = + req->images[retained_images].token_start; + if (canonical_image_start > (uint32_t)req->prompt.len) { + reason = "invalid-conditioning-span"; + goto fail; + } + + ds4_tokens before_image = req->prompt; + before_image.len = (int)canonical_image_start; + size_t before_image_bytes = 0; + char *before_image_text = render_tokens_text( + s->engine, &before_image, &before_image_bytes); + if (!before_image_text || cached_text_bytes > before_image_bytes || + memcmp(query->text + identity_bytes, before_image_text, + cached_text_bytes) != 0) { + free(before_image_text); + reason = "conditioning-byte-frontier-mismatch"; + goto fail; + } + + const ds4_tokens *loaded_tokens = ds4_session_tokens(slot->session); + if (!loaded_tokens || loaded_tokens->len <= 0) { + free(before_image_text); + reason = "missing-loaded-token-prefix"; + goto fail; + } + ds4_tokens_free(effective_prompt); + build_prompt_from_exact_prefix_and_text_suffix( + s->engine, loaded_tokens, + before_image_text + cached_text_bytes, effective_prompt); + free(before_image_text); + + const int effective_image_start = effective_prompt->len; + for (int i = (int)canonical_image_start; i < req->prompt.len; i++) { + ds4_tokens_push(effective_prompt, req->prompt.v[i]); + } + const int64_t delta = + (int64_t)effective_image_start - (int64_t)canonical_image_start; + if (!kv_cache_shift_conditioning_spans( + req->images, req->image_count, retained_images, + delta, effective_prompt->len)) { + reason = "invalid-shifted-conditioning-span"; + goto fail; + } + if (reason_out) *reason_out = "compatible"; + return true; + +fail: + if (reason_out) *reason_out = reason; + return false; +} + +/* Unified disk restore. Text requests retain the existing visible-replay/BPE + * reconstruction path. Multimodal requests search exact vision, retained + * vision-prefix, and zero-image text checkpoints in one ranked query. */ +static int kv_cache_try_load_compatible(server *s, server_slot *slot, + request *req, + ds4_tokens *effective_prompt, + char **loaded_path_out, + uint8_t *loaded_ext_flags_out, + const char **cache_source_out) { + if (cache_source_out) *cache_source_out = "none"; + if (!s || !slot || !req || !s->kv.enabled) return 0; + + if (req->image_count == 0) { + int loaded = kv_cache_try_load(s, slot, req, effective_prompt, + loaded_path_out, + loaded_ext_flags_out); + if (loaded > 0 && cache_source_out) *cache_source_out = "disk-text"; + return loaded; + } + + size_t query_count = 0; + ds4_kvstore_prefix_query *queries = + kv_cache_conditioning_queries(s, req, &query_count); + if (!queries) return 0; + + size_t matched_query = SIZE_MAX; + uint8_t ext_flags = 0; + uint32_t loaded_key_bytes = 0; + int loaded = kv_cache_try_load_best_prefix( + s, slot, queries, query_count, effective_prompt, loaded_path_out, + &ext_flags, &loaded_key_bytes, req->api == API_RESPONSES, + &matched_query); + if (loaded <= 0 || matched_query >= query_count) { + kv_cache_conditioning_queries_free(queries, query_count); + return 0; + } + + const size_t retained_images = req->image_count - matched_query; + const bool complete_conditioning = retained_images == req->image_count; + const ds4_tokens *cached_tokens = NULL; + pthread_mutex_lock(&s->inference_mu); + cached_tokens = ds4_session_tokens(slot->session); + const char *reject_reason = NULL; + bool compatible = kv_cache_loaded_conditioning_matches( + cached_tokens, &req->prompt, req->images, req->image_count, + retained_images, &reject_reason); + if (compatible && retained_images != 0) { + compatible = (ext_flags & KV_EXT_VISION_IDENTITY) && + ds4_session_restore_vision_identities( + slot->session, req->images, retained_images); + if (!compatible) reject_reason = "invalid-conditioning-identity"; + } + if (!compatible) ds4_session_invalidate(slot->session); + pthread_mutex_unlock(&s->inference_mu); + + if (compatible && !complete_conditioning) { + compatible = kv_cache_rebuild_partial_conditioning_prompt( + s, slot, req, &queries[matched_query], retained_images, + loaded_key_bytes, effective_prompt, &reject_reason); + if (!compatible) { + pthread_mutex_lock(&s->inference_mu); + ds4_session_invalidate(slot->session); + pthread_mutex_unlock(&s->inference_mu); + } + } + + if (!compatible) { + server_log(DS4_LOG_WARNING, + "ds4-server: rejected conditioned disk checkpoint tokens=%d images=%zu/%zu reason=%s", + loaded, retained_images, req->image_count, + reject_reason ? reject_reason : "invalid-frontier"); + ds4_tokens_free(effective_prompt); + if (loaded_path_out) { + free(*loaded_path_out); + *loaded_path_out = NULL; + } + if (loaded_ext_flags_out) *loaded_ext_flags_out = 0; + kv_cache_conditioning_queries_free(queries, query_count); + return 0; + } + + const char *source = retained_images == 0 ? "disk-text-prefix" : + complete_conditioning ? "disk-vision" : + "disk-vision-prefix"; + server_log(DS4_LOG_KVCACHE, + "ds4-server: conditioned disk kv hit source=%s images=%zu/%zu cached=%d canonical=%d effective=%d", + source, retained_images, req->image_count, + loaded, req->prompt.len, effective_prompt->len); + if (loaded_ext_flags_out) *loaded_ext_flags_out = ext_flags; + if (cache_source_out) *cache_source_out = source; + kv_cache_conditioning_queries_free(queries, query_count); + return loaded; +} + static int live_text_prefix_prompt(server *s, server_slot *slot, const request *req, ds4_tokens *effective_prompt) { @@ -11212,7 +11702,7 @@ static int server_multimodal_resume_pos(ds4_session *session, const int common = ds4_session_common_prefix(session, prompt); return server_multimodal_resume_frontier( live, common, prompt->len, - ds4_session_vision_state_matches(session, images, image_count)); + ds4_session_vision_prefix_matches(session, images, image_count)); } static int server_session_sync_multimodal(server *s, server_slot *slot, @@ -11948,10 +12438,10 @@ static void *decode_worker_main(void *arg) { * * Clients resend full prompts as text. The worker first tries the old exact * token-prefix hit, then a rendered-text prefix hit for the live checkpoint, - * then disk text-prefix restart snapshots, then a cold prefill. On text-prefix - * hits we build a fresh effective prompt from the checkpoint's exact token - * history plus a newly tokenized string suffix; the canonical full-prompt - * tokens are not sliced because BPE may merge across the byte boundary. Cold + * then compatible disk restart snapshots, then a cold prefill. On rendered- + * prefix hits we build a fresh effective prompt from the checkpoint's exact + * token history plus a newly tokenized string suffix; the canonical full-prompt + * tokens are not sliced because BPE may merge across the byte boundary. Cold * prompt caching is handled before generation: if the stable checkpoint is * shorter than the full prompt, we prefill to that boundary, store it, and * immediately continue to the real prompt. The live graph therefore always @@ -11963,9 +12453,14 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { pthread_mutex_lock(&s->inference_mu); const int old_pos = ds4_session_pos(slot->session); const int common = ds4_session_common_prefix(slot->session, &j->req.prompt); - const bool live_vision_match = + const bool live_vision_exact_match = ds4_session_vision_state_matches(slot->session, j->req.images, j->req.image_count); + const bool live_vision_prefix_match = + ds4_session_vision_prefix_matches(slot->session, + j->req.images, j->req.image_count); + const int multimodal_prefix_cached = server_multimodal_resume_frontier( + old_pos, common, j->req.prompt.len, live_vision_prefix_match); pthread_mutex_unlock(&s->inference_mu); trace_cache_diag cache_diag = {0}; trace_cache_capture(&cache_diag, ds4_session_tokens(slot->session), @@ -11985,7 +12480,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { * exact token-prefix match. Exact token/text/disk matching remains the * fallback when the live state is absent or no longer describes the * request. */ - int cached = live_vision_match ? + int cached = live_vision_exact_match ? responses_live_visible_prefix_prompt(s, slot, &j->req, old_pos, &effective_prompt) : 0; const char *cache_source = cached > 0 ? "responses-visible" : "none"; @@ -11998,7 +12493,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { responses_live_match_ids = j->req.responses_live_call_ids.len; } } - if (cached == 0 && live_vision_match) { + if (cached == 0 && live_vision_exact_match) { cached = responses_live_continuation_prompt(s, slot, &j->req, old_pos, &effective_prompt, &responses_live_match_ids); @@ -12008,7 +12503,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { if (cached > 0) { responses_live_continuation = true; prompt_for_sync = &effective_prompt; - } else if (live_vision_match) { + } else if (live_vision_exact_match) { cached = anthropic_live_continuation_prompt(s, slot, &j->req, old_pos, &effective_prompt, &anthropic_live_match_ids); @@ -12036,7 +12531,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { http_error(j->fd, s->enable_cors, 409, "Anthropic continuation state is not available; retry by replaying the full messages history"); return; - } else if (cached == 0 && live_vision_match) { + } else if (cached == 0 && live_vision_exact_match) { const int rewind_to = live_prefix_rewind_target( ds4_engine_is_glm_dsa(s->engine), old_pos, j->req.prompt.len, common); @@ -12068,7 +12563,15 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { cache_source = cached > 0 ? "memory-token" : "none"; } } - if (cached == 0 && live_vision_match) { + /* A request may append its first image to text KV, or append another image + * after an already-conditioned checkpoint. Exact token continuation is + * sufficient when every retained conditioning span matches and the next + * span begins at or after the live frontier. */ + if (cached == 0 && multimodal_prefix_cached > 0) { + cached = multimodal_prefix_cached; + cache_source = "memory-token"; + } + if (cached == 0 && live_vision_exact_match) { int thinking_cached = thinking_live_visible_prefix_prompt(s, slot, &j->req, old_pos, &effective_prompt); @@ -12082,7 +12585,8 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { int disk_cached = 0; char *disk_cache_path = NULL; uint8_t disk_cache_ext_flags = 0; - if (cached == 0 && live_vision_match) { + const char *disk_cache_source = "none"; + if (cached == 0 && live_vision_exact_match) { int text_cached = live_text_prefix_prompt(s, slot, &j->req, &effective_prompt); if (text_cached > 0) { @@ -12091,37 +12595,53 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { prompt_for_sync = &effective_prompt; } } - if (cached == 0 && old_pos > 0) { - server_log(DS4_LOG_WARNING, - "ds4-server: live kv cache miss%s live=%d prompt=%d common=%d vision=%s reason=%s", - responses_protocol ? " RESPPROTO" : "", - old_pos, j->req.prompt.len, common, - live_vision_match ? "match" : "mismatch", - trace_cache_miss_reason(&cache_diag)); - } + /* A live-slot miss is only an intermediate result while disk fallback is + * enabled. Delay reporting it until after lookup so a successful restore + * is not presented as a cache failure. */ + const bool live_cache_missed = cached == 0 && old_pos > 0; if (multimodal && cached > 0) { server_log(DS4_LOG_KVCACHE, - "ds4-server: multimodal live kv hit images=%zu cached=%d prompt=%d identity=fingerprint-match", - j->req.image_count, cached, prompt_for_sync->len); + "ds4-server: multimodal live kv hit images=%zu cached=%d prompt=%d identity=%s", + j->req.image_count, cached, prompt_for_sync->len, + live_vision_exact_match ? "fingerprint-exact-match" : + "fingerprint-prefix-match"); } if (cached == 0) slot->continued_last_store_tokens = 0; - if (!multimodal && s->kv.enabled && cached == 0 && - old_pos >= s->kv.opt.min_tokens) { + if (s->kv.enabled && cached == 0 && old_pos >= s->kv.opt.min_tokens) { /* Loading a disk snapshot replaces the live Metal session. Persist the * current checkpoint first, otherwise a cache hit for an older prefix * would silently discard the newer conversation state. */ kv_cache_store_current(s, slot, "evict"); } - if (!multimodal && cached == 0) { - disk_cached = kv_cache_try_load(s, slot, &j->req, &effective_prompt, - &disk_cache_path, - &disk_cache_ext_flags); + if (cached == 0) { + disk_cached = kv_cache_try_load_compatible( + s, slot, &j->req, &effective_prompt, &disk_cache_path, + &disk_cache_ext_flags, &disk_cache_source); if (disk_cached > 0) { cached = disk_cached; - cache_source = "disk-text"; + cache_source = disk_cache_source; prompt_for_sync = &effective_prompt; } } + if (live_cache_missed) { + if (cached > 0) { + server_log(DS4_LOG_KVCACHE, + "ds4-server: live kv fallback recovered%s live=%d prompt=%d common=%d vision=%s source=%s cached=%d", + responses_protocol ? " RESPPROTO" : "", + old_pos, j->req.prompt.len, common, + live_vision_exact_match ? "exact-match" : + live_vision_prefix_match ? "prefix-match" : "mismatch", + cache_source, cached); + } else { + server_log(DS4_LOG_WARNING, + "ds4-server: kv cache miss (cold prefill)%s live=%d prompt=%d common=%d vision=%s reason=%s", + responses_protocol ? " RESPPROTO" : "", + old_pos, j->req.prompt.len, common, + live_vision_exact_match ? "exact-match" : + live_vision_prefix_match ? "prefix-match" : "mismatch", + trace_cache_miss_reason(&cache_diag)); + } + } const bool responses_reasoning_state_preserved = cached > 0 && ((!strcmp(cache_source, "responses-visible") || @@ -13221,12 +13741,18 @@ static int job_slot_score(server *s, server_slot *slot, const job *j, if (!s || !slot || !j || slot->busy || slot->assigned) return INT_MIN; if (required_slot >= 0 && slot->id != required_slot) return INT_MIN; if (required_slot == slot->id) return INT_MAX; - if (ds4_session_pos(slot->session) > 0 && - !ds4_session_vision_state_matches(slot->session, - j->req.images, j->req.image_count)) { - return -1; - } + const int live = ds4_session_pos(slot->session); + const bool vision_exact = ds4_session_vision_state_matches( + slot->session, j->req.images, j->req.image_count); + const bool vision_prefix = ds4_session_vision_prefix_matches( + slot->session, j->req.images, j->req.image_count); + if (live > 0 && !vision_prefix) return -1; int common = ds4_session_common_prefix(slot->session, &j->req.prompt); + /* A newly appended conditioning span belongs on this slot only when the + * request extends its complete live token frontier. Earlier text edits + * cannot reuse that state even if they share a shorter prefix. */ + if (live > 0 && !vision_exact && + (common != live || j->req.prompt.len < live)) return -1; return common; } @@ -18457,10 +18983,10 @@ static void test_kv_stub_file(const char *dir, const char *sha, free(path); } -static void test_kv_text_stub_file_model(const char *dir, const char *text, - uint8_t model_id, uint8_t reason, - uint32_t tokens, - uint64_t payload_bytes) { +static void test_kv_text_stub_file_model_ext( + const char *dir, const char *text, + uint8_t model_id, uint8_t reason, uint8_t ext_flags, + uint32_t tokens, uint64_t payload_bytes) { char sha[41]; sha1_bytes_hex(text, strlen(text), sha); char name[44]; @@ -18474,7 +19000,7 @@ static void test_kv_text_stub_file_model(const char *dir, const char *text, } uint8_t h[KV_CACHE_FIXED_HEADER]; - ds4_kvstore_fill_header(h, model_id, 2, reason, 0, tokens, 0, + ds4_kvstore_fill_header(h, model_id, 2, reason, ext_flags, tokens, 0, 32768, 100, 100, payload_bytes); uint8_t text_len[4]; le_put32(text_len, (uint32_t)strlen(text)); @@ -18488,12 +19014,334 @@ static void test_kv_text_stub_file_model(const char *dir, const char *text, free(path); } +static void test_kv_text_stub_file_model(const char *dir, const char *text, + uint8_t model_id, uint8_t reason, + uint32_t tokens, + uint64_t payload_bytes) { + test_kv_text_stub_file_model_ext(dir, text, model_id, reason, 0, + tokens, payload_bytes); +} + static void test_kv_text_stub_file(const char *dir, const char *text, uint8_t reason, uint32_t tokens, uint64_t payload_bytes) { test_kv_text_stub_file_model(dir, text, 0, reason, tokens, payload_bytes); } +static void test_vision_kv_key_requires_exact_image_identity(void) { + const int embd = ds4_engine_embd_dim(NULL); + TEST_ASSERT(embd > 0); + if (embd <= 0) return; + float *conditioning = calloc((size_t)embd, sizeof(conditioning[0])); + TEST_ASSERT(conditioning != NULL); + if (!conditioning) return; + ds4_vision_embedding embedding = { + .data = conditioning, + .token_count = 1, + }; + TEST_ASSERT(vision_embedding_fingerprint_state(NULL, &embedding)); + TEST_ASSERT(embedding.state_fingerprint_valid); + uint8_t first_state_fingerprint[32]; + memcpy(first_state_fingerprint, embedding.state_fingerprint, + sizeof(first_state_fingerprint)); + conditioning[embd - 1] = 1.0f; + TEST_ASSERT(vision_embedding_fingerprint_state(NULL, &embedding)); + TEST_ASSERT(embedding.state_fingerprint_valid); + TEST_ASSERT(memcmp(first_state_fingerprint, embedding.state_fingerprint, + sizeof(first_state_fingerprint)) != 0); + free(conditioning); + + ds4_vision_span image = {0}; + image.token_start = 128; + image.embedding.token_count = 576; + for (size_t i = 0; i < sizeof(image.embedding.fingerprint); i++) { + image.embedding.fingerprint[i] = (uint8_t)i; + image.embedding.state_fingerprint[i] = (uint8_t)(0xa5u ^ i); + } + image.embedding.state_fingerprint_valid = true; + + ds4_vision_span invalid = image; + invalid.embedding.state_fingerprint_valid = false; + char *invalid_key = vision_cache_key_from_text( + "decoded token prefix", strlen("decoded token prefix"), &invalid, 1); + TEST_ASSERT(invalid_key == NULL); + free(invalid_key); + + const char *saved_text = "decoded token prefix"; + const char *future_text = "decoded token prefix and suffix"; + char *saved_key = vision_cache_key_from_text( + saved_text, strlen(saved_text), &image, 1); + char *future_key = vision_cache_key_from_text( + future_text, strlen(future_text), &image, 1); + TEST_ASSERT(saved_key != NULL && future_key != NULL); + TEST_ASSERT(saved_key && future_key && + byte_prefix_match(future_key, strlen(future_key), + saved_key, strlen(saved_key))); + + ds4_vision_span changed = image; + /* The same decoded pixels under different encoder-sidecar state are not + * interchangeable: disk identity follows the actual conditioning block. */ + changed.embedding.state_fingerprint[17] ^= 0xff; + char *changed_key = vision_cache_key_from_text( + future_text, strlen(future_text), &changed, 1); + TEST_ASSERT(changed_key && saved_key && + !byte_prefix_match(changed_key, strlen(changed_key), + saved_key, strlen(saved_key))); + + ds4_vision_span moved = image; + moved.token_start++; + char *moved_key = vision_cache_key_from_text( + future_text, strlen(future_text), &moved, 1); + TEST_ASSERT(moved_key && saved_key && + !byte_prefix_match(moved_key, strlen(moved_key), + saved_key, strlen(saved_key))); + + ds4_vision_span two_images[2] = {image, image}; + two_images[1].token_start = 1024; + two_images[1].embedding.fingerprint[0] ^= 0x80; + two_images[1].embedding.state_fingerprint[0] ^= 0x80; + char *appended_key = vision_cache_key_from_text( + future_text, strlen(future_text), two_images, 2); + TEST_ASSERT(appended_key && saved_key && + !byte_prefix_match(appended_key, strlen(appended_key), + saved_key, strlen(saved_key))); + /* The legacy V2 spelling includes the image count, so a full two-image + * lookup cannot see a one-image checkpoint. The unified lookup asks for + * every retained conditioning prefix; its one-image spelling remains + * byte-prefix compatible with the saved entry. */ + char *retained_key = vision_cache_key_from_text( + future_text, strlen(future_text), two_images, 1); + TEST_ASSERT(retained_key && saved_key && + byte_prefix_match(retained_key, strlen(retained_key), + saved_key, strlen(saved_key))); + TEST_ASSERT(saved_key && + !byte_prefix_match(future_text, strlen(future_text), + saved_key, strlen(saved_key))); + + char tmpl[] = "/tmp/ds4-kv-vision-identity-test.XXXXXX"; + char *dir = mkdtemp(tmpl); + TEST_ASSERT(dir != NULL); + if (dir && saved_key) { + test_kv_text_stub_file_model_ext( + dir, saved_key, 0, KV_REASON_EVICT, + KV_EXT_VISION_IDENTITY, 1024, 0); + kv_disk_cache kc = {0}; + kc.enabled = true; + kc.dir = xstrdup(dir); + kc.opt = kv_cache_default_options(); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, future_key, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) >= 0); + /* A text request must not be able to select a vision payload even if + * it deliberately starts with the private vision-key bytes. */ + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, future_key, 2, 32768, + 0, KV_EXT_VISION_IDENTITY) < 0); + TEST_ASSERT(kv_cache_find_text_prefix( + &kc, future_key, 2, 32768) < 0); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, future_text, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) < 0); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, changed_key, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) < 0); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, moved_key, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) < 0); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, appended_key, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) < 0); + kv_cache_close(&kc); + + /* The boundary is symmetric: an ordinary text payload must not be + * accepted by the image-conditioned loader. */ + test_kv_text_stub_file_model_ext( + dir, saved_key, 0, KV_REASON_EVICT, 0, 1024, 0); + memset(&kc, 0, sizeof(kc)); + kc.enabled = true; + kc.dir = xstrdup(dir); + kc.opt = kv_cache_default_options(); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, future_key, 2, 32768, + 0, KV_EXT_VISION_IDENTITY) >= 0); + TEST_ASSERT(kv_cache_find_text_prefix_filtered( + &kc, future_key, 2, 32768, + KV_EXT_VISION_IDENTITY, 0) < 0); + kv_cache_close(&kc); + + char sha[41], name[44]; + sha1_bytes_hex(saved_key, strlen(saved_key), sha); + snprintf(name, sizeof(name), "%.40s.kv", sha); + char *path = path_join(dir, name); + unlink(path); + free(path); + rmdir(dir); + } + + free(saved_key); + free(future_key); + free(changed_key); + free(moved_key); + free(appended_key); + free(retained_key); +} + +static void test_loaded_conditioning_compatibility(void) { + int prompt_ids[16]; + int cached_ids[16]; + for (int i = 0; i < 16; i++) { + prompt_ids[i] = 1000 + i; + cached_ids[i] = prompt_ids[i]; + } + ds4_tokens prompt = {.v = prompt_ids, .len = 16, .cap = 16}; + ds4_tokens cached = {.v = cached_ids, .len = 2, .cap = 16}; + ds4_vision_span images[2] = {0}; + images[0].token_start = 2; + images[0].embedding.token_count = 3; + images[0].embedding.state_fingerprint_valid = true; + images[1].token_start = 10; + images[1].embedding.token_count = 2; + images[1].embedding.state_fingerprint_valid = true; + const char *reason = NULL; + + /* With no retained image, safety comes from the lookup's rendered-byte + * cap; a different BPE spelling may legitimately use more token IDs. */ + TEST_ASSERT(kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 0, &reason)); + TEST_ASSERT(reason && !strcmp(reason, "compatible")); + cached.len = 18; + TEST_ASSERT(kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 0, &reason)); + + /* A retained image must be completely represented by the checkpoint. */ + cached.len = 4; + TEST_ASSERT(!kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 1, &reason)); + TEST_ASSERT(reason && + !strcmp(reason, "retained-conditioning-crosses-frontier")); + cached.len = 5; + TEST_ASSERT(kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 1, &reason)); + cached.len = 11; + TEST_ASSERT(!kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 2, &reason)); + + cached.len = 12; + TEST_ASSERT(kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 2, &reason)); + + images[0].embedding.state_fingerprint_valid = false; + TEST_ASSERT(!kv_cache_loaded_conditioning_matches( + &cached, &prompt, images, 2, 1, &reason)); + images[0].embedding.state_fingerprint_valid = true; + + ds4_vision_span overlapping[2] = {images[0], images[1]}; + overlapping[1].token_start = 4; + TEST_ASSERT(!kv_cache_loaded_conditioning_matches( + &cached, &prompt, overlapping, 2, 2, &reason)); + TEST_ASSERT(reason && !strcmp(reason, "invalid-conditioning-span")); +} + +static void test_conditioning_span_relocation(void) { + ds4_vision_span images[2] = {0}; + images[0].token_start = 2; + images[0].embedding.token_count = 3; + images[1].token_start = 10; + images[1].embedding.token_count = 2; + + TEST_ASSERT(kv_cache_shift_conditioning_spans( + images, 2, 1, 2, 18)); + TEST_ASSERT(images[0].token_start == 2); + TEST_ASSERT(images[1].token_start == 12); + + images[1].token_start = 10; + TEST_ASSERT(!kv_cache_shift_conditioning_spans( + images, 2, 1, -6, 16)); + TEST_ASSERT(images[1].token_start == 10); + TEST_ASSERT(!kv_cache_shift_conditioning_spans( + images, 2, 1, 10, 16)); + TEST_ASSERT(images[1].token_start == 10); + + images[1].token_start = UINT32_MAX; + TEST_ASSERT(!kv_cache_shift_conditioning_spans( + images, 2, 1, 1, INT_MAX)); + TEST_ASSERT(images[1].token_start == UINT32_MAX); +} + +static void test_kv_cache_lookup_unifies_conditioning_prefixes(void) { + char tmpl[] = "/tmp/ds4-kv-conditioning-prefix-test.XXXXXX"; + char *dir = mkdtemp(tmpl); + TEST_ASSERT(dir != NULL); + if (!dir) return; + + const char *text_prefix = "system and conversation prefix"; + const char *text_past_image = + "system and conversation prefix text after image"; + const char *vision_prefix = + "\036DS4_VISION_KV_V2\n1\nidentity\n\037system and conversation prefix"; + test_kv_text_stub_file_model_ext( + dir, text_prefix, 0, KV_REASON_EVICT, 0, 1200, 0); + /* This entry matches the full rendered request, but includes bytes after + * the first unmatched image and must be excluded by max_key_bytes. */ + test_kv_text_stub_file_model_ext( + dir, text_past_image, 0, KV_REASON_EVICT, 0, 1400, 0); + test_kv_text_stub_file_model_ext( + dir, vision_prefix, 0, KV_REASON_EVICT, + KV_EXT_VISION_IDENTITY, 800, 0); + + kv_disk_cache kc = {0}; + kc.enabled = true; + kc.dir = xstrdup(dir); + kc.opt = kv_cache_default_options(); + ds4_kvstore_prefix_query queries[2] = { + { + .text = + "\036DS4_VISION_KV_V2\n1\nidentity\n\037system and conversation prefix and suffix", + .max_tokens = 1000, + .max_key_bytes = UINT32_MAX, + .required_ext_flags = KV_EXT_VISION_IDENTITY, + }, + { + .text = + "system and conversation prefix text after image and suffix", + .max_tokens = UINT32_MAX, + .max_key_bytes = sizeof("system and conversation prefix") - 1, + .forbidden_ext_flags = KV_EXT_VISION_IDENTITY, + }, + }; + size_t matched_query = SIZE_MAX; + int idx = ds4_kvstore_find_best_prefix( + &kc, queries, 2, 0, 2, 32768, &matched_query); + /* Matching more conditioning is a later semantic frontier even when its + * exact BPE history happens to contain fewer tokens. */ + TEST_ASSERT(idx >= 0); + TEST_ASSERT(idx >= 0 && kc.entry[idx].tokens == 800); + TEST_ASSERT(matched_query == 0); + + /* Once the vision candidate is capped below its frontier, the same search + * falls back to the safe text checkpoint and still excludes the longer + * text entry that crosses the image boundary. */ + queries[0].max_tokens = 700; + matched_query = SIZE_MAX; + idx = ds4_kvstore_find_best_prefix( + &kc, queries, 2, 0, 2, 32768, &matched_query); + TEST_ASSERT(idx >= 0); + TEST_ASSERT(idx >= 0 && kc.entry[idx].tokens == 1200); + TEST_ASSERT(matched_query == 1); + + kv_cache_close(&kc); + const char *keys[] = {text_prefix, text_past_image, vision_prefix}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + char sha[41], name[44]; + sha1_bytes_hex(keys[i], strlen(keys[i]), sha); + snprintf(name, sizeof(name), "%.40s.kv", sha); + char *path = path_join(dir, name); + unlink(path); + free(path); + } + rmdir(dir); +} + static void test_kv_cache_lookup_uses_longest_text_prefix(void) { char tmpl[] = "/tmp/ds4-kv-text-prefix-test.XXXXXX"; char *dir = mkdtemp(tmpl); @@ -19522,6 +20370,10 @@ static void ds4_server_unit_tests_run(void) { test_kv_cache_cold_store_suppresses_duplicate_continued_boundary(); test_kv_cache_file_size_must_fit_budget(); test_sha1_bytes_hex_matches_known_vector(); + test_vision_kv_key_requires_exact_image_identity(); + test_loaded_conditioning_compatibility(); + test_conditioning_span_relocation(); + test_kv_cache_lookup_unifies_conditioning_prefixes(); test_kv_cache_lookup_uses_longest_text_prefix(); test_kv_cache_lookup_rejects_wrong_model(); test_kv_cache_lookup_rejects_stale_payload_abi(); diff --git a/tests/ds4_test.c b/tests/ds4_test.c index cf8bca2c5..56c13aab3 100644 --- a/tests/ds4_test.c +++ b/tests/ds4_test.c @@ -6,6 +6,10 @@ #include bool ds4_test_dspark_cache_window_crop(void); +bool ds4_test_multimodal_sync_finish_policy(void); +bool ds4_test_vision_identity_carries_exact_state_fingerprint(void); +bool ds4_test_vision_identity_keeps_retained_hash(void); +bool ds4_test_vision_prefix_compatibility(void); static ds4_engine *test_engine_fast; static ds4_engine *test_engine_quality; @@ -6781,6 +6785,12 @@ static void test_dspark_verify_depth(void) { #endif static void test_server_unit_group(void) { +#ifndef DS4_NO_GPU + TEST_ASSERT(ds4_test_multimodal_sync_finish_policy()); + TEST_ASSERT(ds4_test_vision_identity_carries_exact_state_fingerprint()); + TEST_ASSERT(ds4_test_vision_identity_keeps_retained_hash()); + TEST_ASSERT(ds4_test_vision_prefix_compatibility()); +#endif ds4_server_unit_tests_run(); } diff --git a/tests/test_glm53_vision_prompt.c b/tests/test_glm53_vision_prompt.c index fb11dc064..7ab29dc3f 100644 --- a/tests/test_glm53_vision_prompt.c +++ b/tests/test_glm53_vision_prompt.c @@ -11,6 +11,15 @@ typedef struct { int total; } progress_counter; +typedef struct { + int calls; +} cancel_counter; + +static bool cancel_after_first_check(void *ud) { + cancel_counter *counter = ud; + return ++counter->calls > 1; +} + static void count_progress(void *ud, const char *event, int current, int total) { (void)event; progress_counter *counter = ud; @@ -86,6 +95,21 @@ int main(int argc, char **argv) { goto done; } + /* Cancellation has one authoritative no-work preflight. A callback that + * changes immediately afterward must not turn an exact no-op sync into an + * interruption that discards the completed image checkpoint. */ + cancel_counter cancel = {0}; + ds4_session_set_cancel(session, cancel_after_first_check, &cancel); + int cancel_race_rc = ds4_session_sync_multimodal( + session, &prompt, &span, 1, error, sizeof(error)); + ds4_session_set_cancel(session, NULL, NULL); + if (cancel_race_rc != 0 || cancel.calls != 1 || + !ds4_session_vision_state_matches(session, &span, 1)) { + snprintf(error, sizeof(error), + "no-work cancellation check discarded image checkpoint"); + goto done; + } + progress_counter progress = {0}; ds4_session_set_progress(session, count_progress, &progress); if (ds4_session_sync_multimodal(session, &prompt, &span, 1,