در یک جریانِ تبدیلِ گفتار به متن، فرضیهٔ زودهنگام ممکن است عوض شود یا کاملاً کنار برود. اگر هر پیام را بی‌قید به متنِ قبلی بچسبانیم، واژه‌های اصلاح‌شده در خروجی تکرار می‌شوند. این درس، یک مدلِ محدود برای نگهداریِ نتیجه‌های موقت و نهایی می‌سازد. رویدادها را خودمان داده‌ایم؛ هیچ صوت، میکروفون، مرورگر یا مدلِ تشخیصِ گفتاری اجرا نمی‌شود.

۱. متنِ موقت را نتیجهٔ نهایی حساب نکنیم

فرضیهٔ موقت هنوز تثبیت نشده است
تصویر مفهومی: فرضیهٔ موقت هنوز تثبیت نشده است؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

هر نتیجه در مدلِ ما فقط دو فیلد دارد: متن و پرچمِ نهایی‌شدن. متن، رشتهٔ خامِ پایتون است و پرچم باید bool واقعی باشد. این دو فیلد، تمامِ پاسخِ یک سرویسِ گفتار نیستند؛ گزینه‌های دیگرِ تشخیص، confidence، زمانِ واژه و فراداده را مدل نمی‌کنیم. بنابراین این فایل، مبدلِ مستقیمِ یک رویدادِ مرورگر یا سرویسِ مشخص نیست.

بخشِ نتیجه‌ها و رویداد در پیش‌نویسِ Web Speech API، میانِ نتیجهٔ موقتِ قابلِ تغییر و نتیجهٔ نهایی فرق می‌گذارد. از همین تمایز برای طراحیِ حالتِ آموزشی استفاده می‌کنیم: چند نتیجهٔ نهایی در ابتدا و سپس چند نتیجهٔ موقت. بخشِ نهاییِ ثبت‌شده در رویدادهای بعدی بازنویسی یا حذف نمی‌شود.

نهایی‌شدن در این قرارداد، تضمینِ درست‌بودنِ عبارت نیست. ممکن است یک سرویس، متنِ اشتباهی را نهایی اعلام کند؛ کدِ حاضر دربارهٔ دقتِ تشخیص قضاوت نمی‌کند. در محاسبهٔ WER، متن با مرجعِ جدا مقایسه می‌شد. اینجا مسئله، نگهداریِ سازگارِ نسخه‌های یک نتیجه است و مرجعِ گفتاری نداریم.

برای هر نشست، نمونهٔ تازه‌ای از TranscriptReducer می‌سازیم. حالت شامل tuple نتیجه‌ها و تعدادِ جایگاه‌های نهایی است. سقفِ آموزشی، ۶۴ جایگاه و ۲۵۶ نقطهٔ کد برای متنِ هر جایگاه است؛ این‌ها حدِ عمومیِ API نیستند. نوشتنِ مستقیم در فیلدهای داخلیِ شیء پشتیبانی نمی‌شود و کنترلِ هم‌زمانیِ چند نویسنده نیز در این مثال وجود ندارد.

۲. resultIndex جایگاهِ نتیجه است، نه جایگاهِ نویسه

جایگاهِ نتیجه با جایگاهِ نویسه یکی نیست
تصویر مفهومی: جایگاهِ نتیجه با جایگاهِ نویسه یکی نیست؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

رویدادِ ورودی، یک شاخص و یک tuple از نتیجه‌های جایگزین می‌دهد. شاخص می‌گوید دنبالهٔ قابلِ جایگزینی از کدام جایگاه آغاز می‌شود؛ جایگاه‌های پیش از آن باقی می‌مانند. باقیِ دنبالهٔ قبلی با ورودیِ تازه عوض می‌شود. واحدِ این شاخص، خانهٔ فهرستِ نتیجه‌هاست؛ نه بایت، نویسه، نمونهٔ صوتی یا زمانِ سپری‌شده.

این رابط از معناهای resultIndex الهام گرفته، ولی فقط متنِ انتخابی و پرچمِ نهایی را می‌بیند. در رویدادِ کامل، تغییرِ گزینه یا فراداده می‌تواند مهم باشد؛ اینجا چنین داده‌ای نداریم. جایگزینیِ دوبارهٔ یک متنِ موقتِ یکسان مجاز است و متن را تکرار نمی‌کند. از برابر بودنِ این دو فیلد، نبودنِ تغییر در همهٔ فیلدهای API را نتیجه نمی‌گیریم.

شاخص باید int واقعی باشد؛ bool به جایِ صفر و یک پذیرفته نیست. شاخص نه می‌تواند داخلِ پیشوندِ نهایی قرار بگیرد و نه از تعدادِ نتیجه‌های فعلی جلوتر برود. در نتیجه، رویدادی که بخواهد جایگاهِ نهاییِ قبلی را دوباره بنویسد خطا می‌دهد. پیشوندِ محفوظ، حتی وقتی متنِ دنباله خالی است، به عنوانِ حالتِ نشست باقی می‌ماند.

متن‌ها را بدونِ افزودنِ فاصله به هم می‌چسبانیم. اگر قطعهٔ نهایی circuit در پایان فاصله دارد، همان فاصله حفظ می‌شود. قطعهٔ تهی نیز مجاز است و می‌تواند جایگاهِ نهاییِ جداگانه‌ای بسازد. تعدادِ جایگاه‌ها با طولِ متن برابر نیست؛ یک شکلک یا ترکیبِ نوشتاری نیز این واحد را به شاخصِ نویسه تبدیل نمی‌کند.

۳. دنبالهٔ موقت را عوض یا حذف کنیم

دنبالهٔ موقت می‌تواند کوتاه شود
تصویر مفهومی: دنبالهٔ موقت می‌تواند کوتاه شود؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

سناریویِ دستی پنج رویداد دارد. ابتدا متنِ موقت cir می‌آید و سپس در همان جایگاه با circuit عوض می‌شود. رویدادِ سوم، circuit را نهایی می‌کند و on را به صورتِ موقت در جایگاهِ بعدی می‌گذارد. در این مرحله، دو متن داریم، ولی فقط یک جایگاه نهایی شده است.

رویدادِ چهارم از شاخصِ یک، دنبالهٔ تهی می‌دهد؛ پس on حذف می‌شود و متنِ نهاییِ پیشین می‌ماند. رویدادِ پنجم، off را در جایگاهِ یک نهایی می‌کند. خروجیِ نهایی circuit off است و دو جایگاهِ نهایی دارد. چسباندنِ تمامِ متن‌های مشاهده‌شده، cir و circuit و on را نیز به‌اشتباه در خروجی نگه می‌داشت.

راهنمایِ رسمیِ قدیمیِ Chrome دربارهٔ Web Speech نیز نمایشِ موقت را از متنِ نهایی جدا می‌کند. اینجا کدِ نمونهٔ آن را منتقل نکرده‌ایم؛ مدلِ پایتونیِ محدود، حذفِ دنباله و کنترلِ ورودی را صریح می‌سنجد. راهنما مربوط به زمینهٔ تاریخیِ خودش است و شاهدِ پشتیبانیِ امروزِ یک مرورگر، زبان یا محصول نیست.

کدِ کامل با Python 3.14.5 و فقط کتابخانهٔ استاندارد اجرا شده است. هر فراخوانی علاوه بر متن‌های نهایی و موقت، قطعه‌هایی را می‌دهد که همین رویداد برای نخستین بار نهایی کرده است. این خروجی از روی تعدادِ جایگاه‌های نهاییِ قبلی ساخته می‌شود؛ جست‌وجوی متنِ تکراری برای حذفِ واژه‌ها انجام نمی‌دهیم.

"""Own bounded event-tail model; no microphone, browser or speech recognizer."""
from dataclasses import dataclass
from itertools import product
import json
import platform

MAX_RESULTS = 64
MAX_TEXT = 256


@dataclass(frozen=True)
class Result:
    text: str
    final: bool


@dataclass(frozen=True)
class Update:
    final_text: str
    interim_text: str
    newly_final: tuple[str, ...]


class TranscriptReducer:
    def __init__(self):
        self.results = ()
        self.committed_count = 0

    def apply(self, result_index, replacements):
        if type(result_index) is not int:
            raise TypeError('result_index must be an actual int, not a character offset')
        if not self.committed_count <= result_index <= len(self.results):
            raise ValueError('index must stay after committed prefix and within current results')
        if type(replacements) is not tuple:
            raise TypeError('replacement tail must be an actual tuple')
        if result_index + len(replacements) > MAX_RESULTS:
            raise ValueError('too many result slots for this teaching profile')
        for result in replacements:
            if type(result) is not Result or type(result.text) is not str or type(result.final) is not bool:
                raise TypeError('actual Result with actual str and bool required')
            if len(result.text) > MAX_TEXT:
                raise ValueError('text exceeds teaching profile')
        candidate = self.results[:result_index] + replacements
        final_count = 0
        interim_seen = False
        for result in candidate:
            if result.final:
                if interim_seen:
                    raise ValueError('final results must precede all interim results')
                final_count += 1
            else:
                interim_seen = True
        update = Update(''.join(r.text for r in candidate[:final_count]),
                        ''.join(r.text for r in candidate[final_count:]),
                        tuple(r.text for r in candidate[self.committed_count:final_count]))
        # Only after complete validation do we install the new finite snapshot.
        self.results = candidate
        self.committed_count = final_count
        return update


def rejects_unchanged(reducer, index, tail):
    before = (reducer.results, reducer.committed_count)
    try:
        reducer.apply(index, tail)
    except (ValueError, TypeError):
        return before == (reducer.results, reducer.committed_count)
    return False


def independent_snapshot_view(snapshot):
    # Global full-snapshot reference, not an incremental tail reducer.
    # It never calls apply and computes the final prefix from the entire tuple.
    flags = [result.final for result in snapshot]
    if any(flags[i] and not all(flags[:i]) for i in range(len(flags))):
        return None
    count = sum(flags)
    return (''.join(result.text for result in snapshot if result.final),
            ''.join(result.text for result in snapshot if not result.final), count)


def run_example():
    reducer = TranscriptReducer()
    events = [(0,(Result('cir',False),)),
              (0,(Result('circuit',False),)),
              (0,(Result('circuit ',True),Result('on',False))),
              (1,()),
              (1,(Result('off',True),))]
    updates = [reducer.apply(index,tail) for index,tail in events]
    checks = {}
    checks['hand_five_event_texts'] = [(u.final_text,u.interim_text)for u in updates] == [('', 'cir'),('', 'circuit'),('circuit ','on'),('circuit ',''),('circuit off','')]
    checks['new_final_pieces_exactly_once'] = [u.newly_final for u in updates] == [(),(),('circuit ',),(),('off',)]
    checks['committed_prefix_cannot_be_rewritten_or_removed'] = rejects_unchanged(reducer,0,()) and rejects_unchanged(reducer,1,(Result('on',True),))
    mixed = TranscriptReducer();mixed.apply(0,(Result('a ',True),Result('b',False),Result('c',False)))
    checks['tail_replacement_retains_earlier_interim'] = mixed.apply(2,(Result('d',False),)) == Update('a ','bd',())
    checks['empty_tail_deletes_interim_only'] = mixed.apply(1,()) == Update('a ','',()) and mixed.committed_count == 1
    pending = TranscriptReducer();pending.apply(0,(Result('hold',False),))
    checks['same_projected_interim_is_idempotent'] = pending.apply(0,(Result('hold',False),)) == Update('','hold',())
    checks['multiple_new_finals_in_one_event'] = pending.apply(0,(Result('a ',True),Result('b',True))) == Update('a b','',('a ','b'))
    fresh = TranscriptReducer()
    checks['independent_sessions_and_empty_event'] = fresh.apply(0,()) == Update('','',()) and fresh.results == () and reducer.results != ()
    bad = TranscriptReducer();bad.apply(0,(Result('old',False),))
    checks['invalid_final_after_interim_is_atomic'] = rejects_unchanged(bad,0,(Result('x',False),Result('y',True)))
    checks['index_type_and_bounds_are_atomic'] = all(rejects_unchanged(bad,i,())for i in (True,1.0,-1,2,10**100))
    checks['result_type_and_profile_are_atomic'] = all([
        rejects_unchanged(bad,0,[Result('x',False)]),rejects_unchanged(bad,0,('x',)),
        rejects_unchanged(bad,0,(Result(1,False),)),rejects_unchanged(bad,0,(Result('x',1),)),
        rejects_unchanged(bad,0,(Result('x'*257,False),)),
        rejects_unchanged(bad,0,(Result('x',False),)*65)])
    unicode = TranscriptReducer()
    unicode_result = unicode.apply(0,(Result('\u06cc\u200c',True),Result('\U0001f642e\u0301',False)))
    checks['raw_unicode_spaces_empty_strings_and_slot_units'] = (unicode_result == Update('\u06cc\u200c','\U0001f642e\u0301',('\u06cc\u200c',))
          and unicode.apply(1,(Result('',True),)) == Update('\u06cc\u200c','',('',))
          and unicode.committed_count == 2)
    boundary = TranscriptReducer()
    checks['inclusive_resource_profile_boundary'] = boundary.apply(0,(Result('x'*256,True),)*64).newly_final == ('x'*256,)*64
    # Enumerate global snapshots, then all legal starts and possible newtails.
    # Reference recomputes the complete candidate by an independent list splice
    # and full-snapshot final-prefix law, retaining the committed input prefix.
    snapshots = [()]
    for length in range(1,4):
        for texts in product('ab',repeat=length):
            for finals in range(length+1):
                snapshots.append(tuple(Result(t,i<finals)for i,t in enumerate(texts)))
    tails = [()]+[tuple(Result(t,f)for t,f in items)
                  for length in range(1,3)for items in product((('x',False),('y',True)),repeat=length)]
    oracle_cases=0;oracle_ok=True
    for snapshot in snapshots:
        original=independent_snapshot_view(snapshot)
        for index in range(original[2],len(snapshot)+1):
            for tail in tails:
                model=TranscriptReducer();model.apply(0,snapshot)
                candidate=list(snapshot);candidate[index:]=list(tail)
                reference=independent_snapshot_view(tuple(candidate))
                if reference is None:
                    okay=rejects_unchanged(model,index,tail)
                else:
                    outcome=model.apply(index,tail)
                    expected_new=tuple(x.text for x in candidate[original[2]:reference[2]])
                    okay=(outcome==Update(reference[0],reference[1],expected_new)
                          and model.results==tuple(candidate))
                oracle_ok=oracle_ok and okay;oracle_cases+=1
    checks['full_snapshot_oracle_cases_inside_ONE_group'] = bool(oracle_ok)
    assert all(type(x)is bool and x for x in checks.values()),checks
    return dict(python=platform.python_version(),dependencies='standard library only',
        scope='synthetic finite top-transcript/finality tail events; no ASR/browser/API execution',
        event_updates=[dict(final_text=u.final_text,interim_text=u.interim_text,newly_final=u.newly_final)for u in updates],
        final_result_slots=reducer.committed_count,final_text=updates[-1].final_text,
        oracle_transition_cases_inside_ONE_group=oracle_cases,raw_spaces_preserved=True,
        microphone_or_model_executed=False,checks=checks)


if __name__ == '__main__':
    print(json.dumps(run_example(),ensure_ascii=False,indent=2))

۴. پیشوندِ نهایی را با مرجعِ مستقل نگه داریم

بخشِ نهایی و مرجعِ مستقل
تصویر مفهومی: بخشِ نهایی و مرجعِ مستقل؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پیش از تغییرِ حالت، نوع، طول و ترتیبِ تمامِ نتیجه‌های جایگزین بررسی می‌شود. اگر نتیجهٔ نهایی بعد از یک نتیجهٔ موقت قرار بگیرد، رویداد رد می‌شود. فقط پس از ساختنِ نمایِ کاملِ معتبر، حالتِ تازه نصب می‌شود. در آزمون‌های خطایِ ورودی، هم tuple قبلی و هم شمارِ جایگاه‌های نهایی باید بدونِ تغییر بمانند.

مرجعِ مستقل، حالت را به صورتِ یک نمایِ کامل بررسی می‌کند. ابتدا تمامِ پرچم‌ها را می‌خواند و قانونِ «نهایی‌ها پیش از موقت‌ها» را می‌سنجد؛ سپس متنِ هر دسته را از کلِ همان نما می‌سازد. برای تولیدِ پاسخ، apply را فراخوانی نمی‌کند و شمارِ نهاییِ ذخیره‌شده در شیء را مرجعِ درست فرض نمی‌گیرد.

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "scope": "synthetic finite top-transcript/finality tail events; no ASR/browser/API execution",
  "event_updates": [
    {
      "final_text": "",
      "interim_text": "cir",
      "newly_final": []
    },
    {
      "final_text": "",
      "interim_text": "circuit",
      "newly_final": []
    },
    {
      "final_text": "circuit ",
      "interim_text": "on",
      "newly_final": [
        "circuit "
      ]
    },
    {
      "final_text": "circuit ",
      "interim_text": "",
      "newly_final": []
    },
    {
      "final_text": "circuit off",
      "interim_text": "",
      "newly_final": [
        "off"
      ]
    }
  ],
  "final_result_slots": 2,
  "final_text": "circuit off",
  "oracle_transition_cases_inside_ONE_group": 777,
  "raw_spaces_preserved": true,
  "microphone_or_model_executed": false,
  "checks": {
    "hand_five_event_texts": true,
    "new_final_pieces_exactly_once": true,
    "committed_prefix_cannot_be_rewritten_or_removed": true,
    "tail_replacement_retains_earlier_interim": true,
    "empty_tail_deletes_interim_only": true,
    "same_projected_interim_is_idempotent": true,
    "multiple_new_finals_in_one_event": true,
    "independent_sessions_and_empty_event": true,
    "invalid_final_after_interim_is_atomic": true,
    "index_type_and_bounds_are_atomic": true,
    "result_type_and_profile_are_atomic": true,
    "raw_unicode_spaces_empty_strings_and_slot_units": true,
    "inclusive_resource_profile_boundary": true,
    "full_snapshot_oracle_cases_inside_ONE_group": true
  }
}

چهل‌ونه نمایِ کوتاه با متن‌های a و b ساخته شده‌اند. برای هرکدام، جایگاه‌های مجاز و دنباله‌های کوتاهِ تازه بررسی می‌شوند؛ ۷۷۷ انتقال در یک گروهِ مقایسه قرار دارند. مرجع با جایگزینیِ برشِ یک فهرستِ کامل، حالتِ انتظار را می‌سازد. نتیجه‌های نامعتبر باید رد شوند و نتیجه‌های معتبر باید متن، جایگاه‌ها و قطعه‌های تازه‌نهایی‌شدهٔ درست داشته باشند.

چهارده گروهِ نام‌دار موفق شدند. حذفِ دنباله، نگهداریِ موقتِ پیش از شاخص، چند نهایی‌شدن در یک رویداد، نشستِ تازه، رویدادِ تهی، مرزهای اندازه و رشتهٔ خامِ یونیکد نیز سنجیده شده‌اند. تعدادِ انتقال‌ها گروهِ تازه محسوب نمی‌شود. این آزمونِ محدود، دقت، تأخیر یا رفتارِ هم‌زمانِ یک سرویسِ تشخیصِ گفتار را اندازه نمی‌گیرد.

این مرحله با رمزگشاییِ حریصانهٔ CTC نیز تفاوت دارد. در آن درس، مسیرِ برچسب‌های قاب به دنباله تبدیل می‌شد؛ اینجا متنِ فرضیه از قبل داده شده است. حذفِ on به معنی blank بودنِ قاب یا وجودِ سکوت نیست. هیچ بخشِ صوتی یا احتمالِ قاب در ورودیِ این کاهش‌دهنده وجود ندارد.

۵. چهار پرسش دربارهٔ تکرار، مرزِ نشست و نهایی‌شدن

نشستِ تازه به ظرفِ تازه نیاز دارد
تصویر مفهومی: نشستِ تازه به ظرفِ تازه نیاز دارد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: چرا on پس از رویدادِ چهارم دیده نمی‌شود؟ پاسخ: دنبالهٔ جایگزینِ آغازشونده در شاخصِ یک تهی است؛ پس موقتِ قبلی از همان نقطه حذف می‌شود. شاخصِ یک، نویسهٔ دومِ circuit نیست. جایگاهِ صفرِ نهایی، همراه با فاصلهٔ انتهایش، حفظ می‌شود.

پرسش دوم: تکرارِ متنِ موقت، متنِ نهایی را دو بار اضافه می‌کند؟ پاسخ: خیر؛ دنباله عوض می‌شود و خروجیِ تازه‌نهایی‌شده فقط از جایگاه‌های تازه می‌آید. با این حال، متنِ یکسان در جایگاهِ تازه ممکن است تکرارِ واقعی باشد؛ نباید با یک مجموعهٔ رشته‌ها، واژه‌های برابر را خودکار حذف کرد.

پرسش سوم: می‌توان نتیجهٔ نهاییِ اشتباه را در همان جایگاه اصلاح کرد؟ پاسخ: این قرارداد چنین رویدادی را رد می‌کند. اصلاحِ بعدیِ انسانی یا نسخهٔ تازهٔ سند به لایه و سیاستِ دیگری نیاز دارد. نهایی‌شدن، فقط مرزِ تغییرپذیریِ همین نشست است و به معنای تأییدِ محتوای گفته‌شده نیست.

پرسش چهارم: نشستِ بعدی از ادامهٔ همین شیء شروع می‌شود؟ پاسخ: خیر؛ ظرفِ تازه می‌خواهد تا پیشوندِ نشستِ قبل واردِ آن نشود. منابع در ۲۶ مهر ۱۴۰۵ خوانده شدند؛ پیش‌نویسِ گروهِ اجتماعیِ Web Speech تاریخِ ۱۵ اکتبرِ ۲۰۲۶ دارد و راهنمایِ Chrome آخرین‌به‌روزرسانیِ ۱۳ ژانویهٔ ۲۰۱۳ را نشان می‌دهد. هیچ‌یک جایِ آزمایشِ سازگاریِ مرورگرِ واقعی را نمی‌گیرد.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۶ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.