mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 18:38:23 -04:00
The first UTF-8 fix closed the cumulative half only. Rule 2 discards a fragment the known text already starts with, and when that fragment is the LEAD BYTE of a new character it looks exactly like a repeat of an older character beginning with the same byte. It was discarded rather than held, its continuation bytes then arrived alone and began the next delta, and utf8_complete_prefix_length only ever inspected the trailing sequence, so a delta invalid at the FRONT went out whole. Through a real Go proto.Unmarshal the review's four-character repro gave 3 deltas, 2 unmarshal failures and a lost transcript. Reachable from the incremental families, not only from voxtral: nemotron_asr's decoder cuts at a byte offset and vibevoice_asr's common_prefix_size compares bytes, so both split characters. Measured over 30,000 randomized incremental traces, 53.28% of Japanese traces and 9.52% of French ones carried at least one delta the Go runtime refuses. Two changes. Rule 2 no longer judges a fragment that ends mid-character, so the lead byte is held instead of swallowed and the character survives intact; the cost is a few duplicated bytes in a shrinking cumulative report, which no pinned family produces. release() additionally drops leading orphan continuation bytes, so no delta can begin mid-character whatever the rules above it decide. Losing a byte keeps the stream alive; emitting one ends the RPC and takes the final_result with it. Post-fix all 60,000 traces produce zero unmarshal failures, and the cumulative streams plus both pure-ASCII incremental streams are byte-identical to the previous commit, so nothing changed for the families already working. The weight-dtype allow list moves to family_gate, where it is stdlib-only and pinned by a test rather than only by a comment. Two comment citations corrected. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
437 lines
20 KiB
C++
437 lines
20 KiB
C++
// Unit tests for stream_delta. Standard library only. The harness compiles this
|
|
// as a single translation unit, so the implementation is included directly.
|
|
//
|
|
// The traces below are transcribed from the pinned upstream sessions rather
|
|
// than invented, because the whole reason this unit exists is that the four
|
|
// streaming ASR families do NOT agree on what partial_text means.
|
|
|
|
#include "stream_delta.cpp"
|
|
|
|
#include <cstdio>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
static int failures = 0;
|
|
|
|
static void check(bool ok, const std::string &name) {
|
|
if (!ok) {
|
|
failures++;
|
|
fprintf(stderr, "FAIL: %s\n", name.c_str());
|
|
} else {
|
|
fprintf(stderr, "ok: %s\n", name.c_str());
|
|
}
|
|
}
|
|
|
|
static void check_eq(const std::string &got, const std::string &want,
|
|
const std::string &name) {
|
|
check(got == want, name + " (got \"" + got + "\" want \"" + want + "\")");
|
|
}
|
|
|
|
using audiocpp_backend::TranscriptDeltaTracker;
|
|
|
|
// Feeds a whole trace and returns what a client appending every emitted
|
|
// fragment would end up holding, which is the only property that matters.
|
|
static std::string client_view(TranscriptDeltaTracker &tracker,
|
|
const std::vector<std::string> &partials,
|
|
std::vector<std::string> *emitted = nullptr) {
|
|
std::string view;
|
|
for (const auto &partial : partials) {
|
|
const std::string fragment = tracker.observe(partial);
|
|
if (emitted != nullptr && !fragment.empty()) {
|
|
emitted->push_back(fragment);
|
|
}
|
|
view += fragment;
|
|
}
|
|
return view;
|
|
}
|
|
|
|
// nemotron_asr: decoder.cpp emits current_text.substr(emitted_text.size()) on
|
|
// every non-blank token, i.e. INCREMENTAL fragments, through the stream event
|
|
// sink during finalize().
|
|
static void test_incremental_family() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string view =
|
|
client_view(tracker, {"Local", " AI", " now", " speaks."}, &emitted);
|
|
check_eq(view, "Local AI now speaks.", "incremental deltas concatenate");
|
|
check(emitted.size() == 4, "incremental family emits one fragment per partial");
|
|
check_eq(tracker.assembled(), "Local AI now speaks.",
|
|
"incremental family assembles the whole transcript");
|
|
check_eq(tracker.reconcile("Local AI now speaks."), "",
|
|
"a final result the deltas already cover adds nothing");
|
|
}
|
|
|
|
// voxtral_realtime: process_one_stream_chunk sets partial_text to
|
|
// tokenizer_.decode(streaming_token_ids_), the WHOLE accumulated hypothesis,
|
|
// and process_available_stream_chunks then hands the SAME event to both the
|
|
// sink and the caller, so every partial arrives twice.
|
|
static void test_cumulative_family_with_duplicate_delivery() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string view = client_view(
|
|
tracker, {"Local", "Local", "Local AI", "Local AI", "Local AI now",
|
|
"Local AI now"},
|
|
&emitted);
|
|
check_eq(view, "Local AI now", "cumulative partials are not repeated to the client");
|
|
check(emitted.size() == 3,
|
|
"the duplicate delivery of each cumulative event emits nothing twice");
|
|
check_eq(emitted.empty() ? "" : emitted[0], "Local", "first cumulative fragment");
|
|
check_eq(emitted.size() < 2 ? "" : emitted[1], " AI", "second cumulative fragment");
|
|
check_eq(emitted.size() < 3 ? "" : emitted[2], " now", "third cumulative fragment");
|
|
}
|
|
|
|
// The rule that separates the two: text the client has ALREADY been sent is
|
|
// never sent again, whatever convention produced it.
|
|
static void test_already_delivered_text_is_never_resent() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("hello world"), "hello world", "first fragment");
|
|
check_eq(tracker.observe("hello world"), "", "an identical repeat emits nothing");
|
|
check_eq(tracker.observe("hello"), "",
|
|
"a shortened hypothesis emits nothing rather than duplicating a prefix");
|
|
check_eq(tracker.assembled(), "hello world",
|
|
"a shortened hypothesis does not shrink what the client holds");
|
|
}
|
|
|
|
static void test_empty_partials_are_ignored() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe(""), "", "an empty partial emits nothing");
|
|
check_eq(tracker.observe("a"), "a", "a real partial after an empty one still emits");
|
|
check_eq(tracker.observe(""), "", "a later empty partial emits nothing");
|
|
check_eq(tracker.assembled(), "a", "empty partials do not disturb the assembly");
|
|
}
|
|
|
|
// The offline fallback: a family with no streaming ASR runs once, so nothing is
|
|
// ever observed and the reconciliation IS the single delta the RPC promises.
|
|
static void test_offline_fallback_is_one_delta() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.reconcile("the whole transcript"), "the whole transcript",
|
|
"with no partials the final text is emitted whole");
|
|
check_eq(tracker.assembled(), "the whole transcript",
|
|
"the reconciliation is recorded as delivered");
|
|
check_eq(tracker.reconcile("the whole transcript"), "",
|
|
"reconciling twice does not duplicate");
|
|
}
|
|
|
|
// A streaming family whose partials stopped short of the final text: the tail
|
|
// is emitted so that appending every delta still equals final_result.text.
|
|
static void test_reconcile_emits_the_tail() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("Local AI"), "Local AI", "partial arrives");
|
|
check_eq(tracker.reconcile("Local AI now speaks."), " now speaks.",
|
|
"the untold tail of the final text is emitted");
|
|
check_eq(tracker.assembled(), "Local AI now speaks.", "tail is recorded");
|
|
}
|
|
|
|
// Divergence. nemotron's decoder has a rewrite branch: when the new hypothesis
|
|
// is NOT an extension of what it already emitted, it emits the whole new text.
|
|
// Nothing can retract a fragment already written to the wire, so the tracker
|
|
// must not try: it emits nothing further and leaves final_result authoritative.
|
|
static void test_divergent_final_text_is_not_appended() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("the cat"), "the cat", "first hypothesis");
|
|
check_eq(tracker.reconcile("the dog"), "",
|
|
"a final text that contradicts the deltas is not appended to them");
|
|
check_eq(tracker.assembled(), "the cat",
|
|
"a contradicted assembly is left as it was actually sent");
|
|
}
|
|
|
|
static void test_empty_final_text() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("something"), "something", "partial arrives");
|
|
check_eq(tracker.reconcile(""), "", "an empty final text emits nothing");
|
|
check_eq(tracker.assembled(), "something", "an empty final text changes nothing");
|
|
}
|
|
|
|
// A whitespace-only fragment is real text: the space between two words is
|
|
// exactly what an incremental family delivers on its own.
|
|
static void test_whitespace_fragments_survive() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("one"), "one", "word");
|
|
check_eq(tracker.observe(" "), " ", "a bare separator is emitted");
|
|
check_eq(tracker.observe("two"), "two", "next word");
|
|
check_eq(tracker.assembled(), "one two", "separator is kept in the assembly");
|
|
}
|
|
|
|
// The ambiguity this unit cannot resolve, pinned so that a future reader sees
|
|
// the choice rather than rediscovering it: a fragment that EXTENDS everything
|
|
// delivered so far is read as a cumulative report, because that is what every
|
|
// cumulative family produces on every event, while an incremental family
|
|
// producing one is the rare coincidence of a fragment repeating the whole
|
|
// transcript so far.
|
|
static void test_prefix_extension_is_read_as_cumulative() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("I"), "I", "first fragment");
|
|
check_eq(tracker.observe("I'm"), "'m",
|
|
"a fragment extending the assembly is treated as a cumulative report");
|
|
check_eq(tracker.assembled(), "I'm", "cumulative reading assembles once");
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// UTF-8 boundaries
|
|
//
|
|
// TranscriptStreamResponse.delta is a proto3 `string`, and the wire format
|
|
// REQUIRES a string field to be valid UTF-8. The C++ runtime serializes an
|
|
// invalid one with at most a warning; the Go runtime refuses to unmarshal it,
|
|
// so the client loses every delta AND the final_result still to come.
|
|
//
|
|
// Not hypothetical. voxtral_realtime reports the whole hypothesis as
|
|
// tokenizer_.decode(streaming_token_ids_), and that decode is a pure
|
|
// concatenation of raw token BYTES (tokenizer_text.cpp:171-183), so a
|
|
// multi-byte character is split across token boundaries and the cumulative
|
|
// difference between two consecutive reports is a lone continuation byte.
|
|
// --------------------------------------------------------------------------
|
|
|
|
// True when `text` is well-formed UTF-8. Written out here rather than reused
|
|
// from the implementation on purpose: a test that shares the implementation's
|
|
// idea of a boundary cannot catch the implementation's idea being wrong.
|
|
static bool is_valid_utf8(const std::string &text) {
|
|
size_t i = 0;
|
|
while (i < text.size()) {
|
|
const auto lead = static_cast<unsigned char>(text[i]);
|
|
size_t length = 0;
|
|
if ((lead & 0x80) == 0x00) {
|
|
length = 1;
|
|
} else if ((lead & 0xE0) == 0xC0) {
|
|
length = 2;
|
|
} else if ((lead & 0xF0) == 0xE0) {
|
|
length = 3;
|
|
} else if ((lead & 0xF8) == 0xF0) {
|
|
length = 4;
|
|
} else {
|
|
return false;
|
|
}
|
|
if (i + length > text.size()) {
|
|
return false;
|
|
}
|
|
for (size_t k = 1; k < length; ++k) {
|
|
if ((static_cast<unsigned char>(text[i + k]) & 0xC0) != 0x80) {
|
|
return false;
|
|
}
|
|
}
|
|
i += length;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Written as byte escapes so the test does not depend on the encoding of this
|
|
// source file.
|
|
static const std::string kEAcute = "\xC3\xA9"; // 2 bytes
|
|
static const std::string kEuro = "\xE2\x82\xAC"; // 3 bytes
|
|
static const std::string kEmoji = "\xF0\x9F\x8E\xA7"; // 4 bytes
|
|
|
|
// A cumulative family advancing its hypothesis one BYTE at a time, which is
|
|
// what voxtral_realtime does across a multi-byte character.
|
|
static void test_cumulative_split_multibyte_character() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string full = "5" + kEuro;
|
|
std::vector<std::string> partials;
|
|
for (size_t n = 1; n <= full.size(); ++n) {
|
|
partials.push_back(full.substr(0, n));
|
|
}
|
|
const std::string view = client_view(tracker, partials, &emitted);
|
|
|
|
check_eq(view, full, "a byte-at-a-time cumulative report still assembles");
|
|
for (size_t i = 0; i < emitted.size(); ++i) {
|
|
check(is_valid_utf8(emitted[i]),
|
|
"cumulative fragment " + std::to_string(i) + " is valid UTF-8");
|
|
}
|
|
check_eq(tracker.reconcile(full), "", "the final text adds nothing");
|
|
}
|
|
|
|
// An incremental family splitting a character across two fragments.
|
|
static void test_incremental_split_multibyte_character() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string view = client_view(
|
|
tracker, {"caf" + kEAcute.substr(0, 1), kEAcute.substr(1), " au lait"},
|
|
&emitted);
|
|
|
|
check_eq(view, "caf" + kEAcute + " au lait",
|
|
"an incremental split character still assembles");
|
|
for (size_t i = 0; i < emitted.size(); ++i) {
|
|
check(is_valid_utf8(emitted[i]),
|
|
"incremental fragment " + std::to_string(i) + " is valid UTF-8");
|
|
}
|
|
check(emitted.size() == 3, "one fragment out per partial, none swallowed");
|
|
if (emitted.size() == 3) {
|
|
check_eq(emitted[0], "caf", "the lead byte of the character is held back");
|
|
check_eq(emitted[1], kEAcute,
|
|
"the held byte is merged into the next fragment, not sent alone");
|
|
check_eq(emitted[2], " au lait", "the rest follows unchanged");
|
|
}
|
|
}
|
|
|
|
// A 4 byte character split three ways, so the held-back buffer has to survive
|
|
// more than one round.
|
|
static void test_four_byte_character_split_three_ways() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string view =
|
|
client_view(tracker,
|
|
{"listen " + kEmoji.substr(0, 1), kEmoji.substr(1, 2),
|
|
kEmoji.substr(3), " now"},
|
|
&emitted);
|
|
check_eq(view, "listen " + kEmoji + " now",
|
|
"a 4 byte character survives three splits");
|
|
for (size_t i = 0; i < emitted.size(); ++i) {
|
|
check(is_valid_utf8(emitted[i]),
|
|
"4 byte fragment " + std::to_string(i) + " is valid UTF-8");
|
|
}
|
|
}
|
|
|
|
// The held-back bytes must reach the client. reconcile can always flush them,
|
|
// because the final text is complete by construction.
|
|
static void test_reconcile_flushes_a_held_back_sequence() {
|
|
TranscriptDeltaTracker tracker;
|
|
const std::string first = tracker.observe("done" + kEuro.substr(0, 2));
|
|
check_eq(first, "done", "the incomplete trailing sequence is held back");
|
|
check(is_valid_utf8(first), "what was emitted is valid UTF-8");
|
|
const std::string tail = tracker.reconcile("done" + kEuro);
|
|
check_eq(tail, kEuro, "reconcile flushes the completed character");
|
|
check(is_valid_utf8(tail), "the flushed tail is valid UTF-8");
|
|
check_eq(tracker.assembled(), "done" + kEuro, "the client holds the whole text");
|
|
}
|
|
|
|
// Held-back bytes are not lost track of: the next cumulative report emits the
|
|
// whole character rather than only the bytes that just arrived.
|
|
static void test_held_bytes_join_the_next_fragment() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("a" + kEuro.substr(0, 1)), "a",
|
|
"only the complete prefix goes out");
|
|
check_eq(tracker.observe("a" + kEuro), kEuro,
|
|
"the next cumulative report emits the whole character at once");
|
|
check_eq(tracker.assembled(), "a" + kEuro, "assembly is correct");
|
|
}
|
|
|
|
// A whole multi-byte character arriving at once must NOT be held back: holding
|
|
// a complete sequence would stall every stream by one character.
|
|
static void test_a_complete_character_is_not_held() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe("x" + kEuro), "x" + kEuro,
|
|
"a fragment ending on a boundary is emitted immediately");
|
|
check_eq(tracker.observe("x" + kEuro + kEmoji), kEmoji,
|
|
"and so is the next one");
|
|
}
|
|
|
|
// Bytes that can never complete must not be held forever: a stray continuation
|
|
// byte or an invalid lead is passed through rather than stalling the stream.
|
|
// Repairing a family's malformed output is not something a delta tracker can do
|
|
// without altering the transcript.
|
|
static void test_undecodable_bytes_are_not_held_forever() {
|
|
TranscriptDeltaTracker tracker;
|
|
check_eq(tracker.observe(std::string("ok\x80")), std::string("ok\x80"),
|
|
"a stray continuation byte is passed through, not held");
|
|
check_eq(tracker.observe(std::string("ok\x80") + "next"), "next",
|
|
"the stream continues");
|
|
|
|
// A lead byte the encoding does not define (0xF8 and above). Nothing can
|
|
// ever complete it, so holding it would stall the stream for good.
|
|
TranscriptDeltaTracker invalid_lead;
|
|
check_eq(invalid_lead.observe(std::string("ok\xFE")), std::string("ok\xFE"),
|
|
"an undefined lead byte is passed through, not held");
|
|
check_eq(invalid_lead.observe(std::string("ok\xFE") + "more"), "more",
|
|
"the stream continues past an undefined lead byte");
|
|
|
|
// The same byte followed by continuation bytes, which is the shape that
|
|
// looks most like a real sequence waiting to be completed.
|
|
TranscriptDeltaTracker invalid_run;
|
|
check_eq(invalid_run.observe(std::string("\xFE\x80\x80")), std::string("\xFE\x80\x80"),
|
|
"an undefined lead with continuations is passed through");
|
|
|
|
// Five continuation bytes with no lead in sight. They are DROPPED, not
|
|
// held: nothing can ever precede them, so holding would stall the stream
|
|
// for good, and emitting them would put invalid UTF-8 on the wire. See
|
|
// test_a_fragment_never_begins_mid_character.
|
|
TranscriptDeltaTracker orphans;
|
|
check_eq(orphans.observe(std::string("\x80\x80\x80\x80\x80")), "",
|
|
"a run of orphan continuation bytes is dropped, not emitted");
|
|
check_eq(orphans.observe("after"), "after",
|
|
"and the stream continues past them");
|
|
}
|
|
|
|
// THE SECOND HALF OF THE SAME BUG, and the one that survived fix round 1.
|
|
//
|
|
// Rule 2 discards a fragment the known text already starts with. When that
|
|
// fragment is the LEAD BYTE of a NEW character it looks exactly like a repeat of
|
|
// an earlier character beginning with the same byte, so it was discarded and
|
|
// never held. Its continuation bytes then arrived on their own and began the
|
|
// next delta, which is invalid UTF-8 at the FRONT, and utf8_complete_prefix_length
|
|
// only ever inspected the TRAILING sequence.
|
|
//
|
|
// Reachable from shipping families, not synthetic: nemotron_asr's
|
|
// decoder.cpp:550 cuts at a BYTE offset (current_text.substr(emitted_text.size()))
|
|
// and vibevoice_asr's common_prefix_size (session.cpp:80-86) compares BYTES, so
|
|
// both split characters mid-sequence. The trace below is exactly how they split
|
|
// "ssee" spelled with the German sharp s, an e-acute, a euro sign and an o-grave,
|
|
// three of which begin with the same 0xC3 lead byte.
|
|
static void test_a_repeated_lead_byte_is_not_swallowed() {
|
|
TranscriptDeltaTracker tracker;
|
|
std::vector<std::string> emitted;
|
|
const std::string sharp_s = "\xC3\x9F"; // U+00DF
|
|
const std::string e_acute = "\xC3\xA9"; // U+00E9
|
|
const std::string euro = "\xE2\x82\xAC"; // U+20AC
|
|
const std::string o_grave = "\xC3\xB2"; // U+00F2
|
|
const std::string full = sharp_s + e_acute + euro + o_grave;
|
|
|
|
const std::string view = client_view(tracker,
|
|
{sharp_s.substr(0, 1), sharp_s.substr(1),
|
|
e_acute.substr(0, 1),
|
|
e_acute.substr(1) + euro,
|
|
o_grave.substr(0, 1), o_grave.substr(1)},
|
|
&emitted);
|
|
|
|
for (size_t i = 0; i < emitted.size(); ++i) {
|
|
check(is_valid_utf8(emitted[i]),
|
|
"repeated-lead fragment " + std::to_string(i) + " is valid UTF-8");
|
|
}
|
|
check_eq(view, full, "no character is lost to a repeated lead byte");
|
|
check_eq(tracker.reconcile(full), "", "the final text adds nothing");
|
|
}
|
|
|
|
// The same shape one layer down, as a backstop: a fragment that BEGINS with
|
|
// orphan continuation bytes must never go on the wire, whatever produced it.
|
|
// Dropping bytes keeps the stream alive; emitting them ends it, and takes the
|
|
// final_result that was still to come with it.
|
|
static void test_a_fragment_never_begins_mid_character() {
|
|
TranscriptDeltaTracker tracker;
|
|
const std::string first = tracker.observe(std::string("\xA9") + "rest");
|
|
check(is_valid_utf8(first), "a leading orphan continuation byte is not emitted");
|
|
check_eq(first, "rest", "the rest of the fragment still goes out");
|
|
|
|
TranscriptDeltaTracker all_orphans;
|
|
check_eq(all_orphans.observe(std::string("\x82\xAC")), "",
|
|
"a fragment that is nothing but orphans emits nothing");
|
|
check_eq(all_orphans.assembled(), "",
|
|
"and nothing is recorded as delivered");
|
|
check_eq(all_orphans.observe("after"), "after", "the stream continues");
|
|
}
|
|
|
|
int main() {
|
|
test_incremental_family();
|
|
test_cumulative_family_with_duplicate_delivery();
|
|
test_already_delivered_text_is_never_resent();
|
|
test_empty_partials_are_ignored();
|
|
test_offline_fallback_is_one_delta();
|
|
test_reconcile_emits_the_tail();
|
|
test_divergent_final_text_is_not_appended();
|
|
test_empty_final_text();
|
|
test_whitespace_fragments_survive();
|
|
test_prefix_extension_is_read_as_cumulative();
|
|
test_cumulative_split_multibyte_character();
|
|
test_incremental_split_multibyte_character();
|
|
test_four_byte_character_split_three_ways();
|
|
test_reconcile_flushes_a_held_back_sequence();
|
|
test_held_bytes_join_the_next_fragment();
|
|
test_a_complete_character_is_not_held();
|
|
test_undecodable_bytes_are_not_held_forever();
|
|
test_a_repeated_lead_byte_is_not_swallowed();
|
|
test_a_fragment_never_begins_mid_character();
|
|
if (failures) {
|
|
fprintf(stderr, "%d check(s) failed\n", failures);
|
|
return 1;
|
|
}
|
|
fprintf(stderr, "all stream_delta checks passed\n");
|
|
return 0;
|
|
}
|