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

۱. سکوت و ورودی نامنطبق دو رویداد متفاوت‌اند

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

در توصیه‌نامهٔ VoiceXML 2.0، noinput برای پاسخ‌ندادن در بازهٔ انتظار و nomatch برای ورودیِ بازشناخته‌نشده تعریف شده‌اند. بخش timeout فاصلهٔ انتظار پس از آخرین پیام را توضیح می‌دهد. این تفکیک، انگیزهٔ مدل ماست؛ پیاده‌سازی کل مفسر VoiceXML، grammar یا الگوریتم تشخیص گفتار نیست.

چهار نوع رویداد خارجی داریم: start، match، nomatch و hangup. start فقط خبرِ فرضیِ آغاز ورودی است؛ از فایل صوتی استخراج نشده است. match و nomatch نیز نتیجه‌های از پیش داده‌شده‌اند، نه خروجی مدل اجراشده. برخلاف این رویدادها، noinput را شبیه‌ساز خودش از رسیدن مهلتِ فعال تولید می‌کند.

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

۲. مهلت فقط تا آغاز ورودی فعال است

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

حالت LISTENING یعنی هنوز منتظر آغاز ورودی هستیم و deadline داریم. start آن را به IN_INPUT می‌برد و deadline را تهی می‌کند. در این حالت، پایان افق آزمایش هم noinput تازه نمی‌سازد. اگر نتیجه هرگز نرسد، مدل در IN_INPUT می‌ماند؛ این رفتار نشان می‌دهد تایمر پایان بازشناسی، مسئله‌ای جداست و در این کد پیاده نشده است.

ضمیمهٔ زمانی VoiceXML نیز انتظار پیش از نخستین DTMF را از مهلت‌های بعدی جدا می‌کند؛ پس از نخستین رقم، timeout اولیه دیگر همان نقش را ندارد. اینجا عدد DTMF، طول سکوت صوت و انتهای جمله را تحلیل نمی‌کنیم. برای جزئیات پیشوند و پایان رشتهٔ رقم، آزمون منوی پیشوندی DTMF مرحلهٔ متفاوتی را پوشش می‌دهد.

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

۳. ترتیب مرزی و بازآزمایی محدود را صریح تعریف کنیم

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

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

اگر start یک میلی‌ثانیه دیرتر برسد، noinput قبلی ثبت شده است؛ اگر بودجهٔ تلاش باقی باشد، start مربوط به انتظار تازه خواهد بود وگرنه پس از پایان نادیده گرفته می‌شود. اگر start و match هم‌زمان باشند، ترتیب start سپس match معتبر است؛ ترتیب معکوس خطا می‌دهد. زمان‌های نامرتب را خودکار مرتب نمی‌کنیم، چون مرتب‌سازی می‌تواند علت خطای تولیدکنندهٔ رویداد را پنهان کند.

دو شمارندهٔ مجزا برای noinput و nomatch داریم، ولی مجموع آن‌ها بودجهٔ تلاش را مصرف می‌کند. پس از رسیدن مجموع به سقف، فقط برچسب fallback آماده می‌شود. این سقف یک تا چهار، سیاست منابع همین مثال است و معادل قواعد شمارندهٔ catch در VoiceXML نیست. برچسب matched هم فقط پایان موفقِ فرضی است؛ تماس منتقل نمی‌شود.

۴. کد کامل و خط زمانی دستی را مقایسه کنیم

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

فایل ivr_noinput_deadline.py با Python 3.14.5 اجرا شده و وابستگی خارجی ندارد. زمان‌ها int واقعی‌اند و bool به‌جای عدد پذیرفته نیست. شروع و افق در صفر تا ۶۰۰۰۰ میلی‌ثانیه، timeout در یک تا ۱۰۰۰۰ و حداکثر ۳۲ رویداد داریم. هر رکورد دقیقاً زمان و نوع را دارد؛ فیلد ناشناخته رد می‌شود.

"""Offline synthetic IVR input-start deadline model; no clock, audio or calls."""
from dataclasses import asdict, dataclass, replace
from itertools import groupby
import copy
import json
import platform


@dataclass(frozen=True)
class State:
    phase: str
    deadline_ms: int | None
    noinput_count: int = 0
    nomatch_count: int = 0
    route_label: str | None = None


def integer(value, low, high):
    if type(value) is not int or not low <= value <= high:
        raise ValueError('integer outside contract')


def simulate(events, *, start_ms=0, horizon_ms=2500, timeout_ms=1000, max_failures=3):
    integer(start_ms, 0, 60000)
    integer(horizon_ms, start_ms, 60000)
    integer(timeout_ms, 1, 10000)
    integer(max_failures, 1, 4)
    if type(events) is not list or len(events) > 32:
        raise ValueError('expected at most 32 event records')
    previous = start_ms
    for event in events:
        if type(event) is not dict or set(event) != {'at_ms', 'kind'}:
            raise ValueError('exact event fields required')
        integer(event['at_ms'], previous, horizon_ms)
        if type(event['kind']) is not str or event['kind'] not in {'start', 'match', 'nomatch', 'hangup'}:
            raise ValueError('unknown event kind')
        previous = event['at_ms']

    state = State('LISTENING', start_ms + timeout_ms)
    trace = []

    def failure(at, kind):
        nonlocal state
        ni = state.noinput_count + (kind == 'noinput')
        nm = state.nomatch_count + (kind == 'nomatch')
        if ni + nm >= max_failures:
            state = State('TERMINAL', None, ni, nm, 'fallback')
        else:
            # Retry prompt duration is assumed zero by this teaching model.
            state = State('LISTENING', at + timeout_ms, ni, nm)
        trace.append({'at_ms': at, 'reason': kind})

    def advance(limit, inclusive):
        while state.phase == 'LISTENING' and (
            state.deadline_ms < limit or (inclusive and state.deadline_ms == limit)
        ):
            failure(state.deadline_ms, 'noinput')

    for at, group in groupby(events, key=lambda event: event['at_ms']):
        advance(at, False)
        # All external events at this exact time precede its deadline expiration.
        for event in group:
            if state.phase == 'TERMINAL':
                continue
            kind = event['kind']
            if kind == 'hangup':
                state = replace(state, phase='TERMINAL', deadline_ms=None, route_label='ended')
                trace.append({'at_ms': at, 'reason': kind})
            elif kind == 'start':
                if state.phase != 'LISTENING':
                    raise ValueError('start requires LISTENING')
                state = replace(state, phase='IN_INPUT', deadline_ms=None)
                trace.append({'at_ms': at, 'reason': kind})
            elif kind in {'match', 'nomatch'}:
                if state.phase != 'IN_INPUT':
                    raise ValueError('recognition result requires supplied start')
                if kind == 'nomatch':
                    failure(at, 'nomatch')
                else:
                    state = replace(state, phase='TERMINAL', deadline_ms=None, route_label='matched')
                    trace.append({'at_ms': at, 'reason': kind})
        advance(at, True)
    advance(horizon_ms, True)
    return {'state': asdict(state), 'trace': trace}


def e(at, kind):
    return {'at_ms': at, 'kind': kind}


def rejects(fn):
    try:
        fn()
    except ValueError:
        return True
    return False


def run_example():
    events = [e(1000, 'start'), e(1200, 'nomatch')]
    before = copy.deepcopy(events)
    mixed = simulate(events)
    hand_state = {'phase': 'LISTENING', 'deadline_ms': 3200, 'noinput_count': 1,
                  'nomatch_count': 1, 'route_label': None}
    hand_trace = [e(1000, 'start'), e(1200, 'nomatch'), e(2200, 'noinput')]
    # Different trace field name from event records; no algorithm call in oracle.
    hand_trace = [{'at_ms': row['at_ms'], 'reason': row['kind']} for row in hand_trace]
    silence = simulate([], horizon_ms=3000)
    oracle_cases = []
    for start in [0, 7]:
        for timeout in [1, 17, 1000]:
            for cap in [1, 2, 4]:
                for elapsed in [0, timeout - 1, timeout, 2 * timeout, 4 * timeout + 1]:
                    count = min(cap, elapsed // timeout)
                    answer = simulate([], start_ms=start, horizon_ms=start + elapsed,
                                      timeout_ms=timeout, max_failures=cap)
                    expected = {'phase': 'TERMINAL' if count == cap else 'LISTENING',
                                'deadline_ms': None if count == cap else start + (count + 1) * timeout,
                                'noinput_count': count, 'nomatch_count': 0,
                                'route_label': 'fallback' if count == cap else None}
                    expected_times = [start + k * timeout for k in range(1, count + 1)]
                    oracle_cases.append(answer['state'] == expected and
                                        [x['at_ms'] for x in answer['trace']] == expected_times)
    checks = {
        'mixed_timeline_matches_literal_hand_answer': mixed == {'state': hand_state, 'trace': hand_trace},
        'before_deadline_has_no_failure': simulate([], horizon_ms=999)['trace'] == [],
        'deadline_without_input_expires_inclusively': simulate([], horizon_ms=1000)['trace'] == [{'at_ms': 1000, 'reason': 'noinput'}],
        'input_start_at_exact_deadline_wins': simulate([e(1000, 'start')], horizon_ms=1000)['state']['phase'] == 'IN_INPUT' and simulate([e(1000, 'start')])['state']['noinput_count'] == 0,
        'input_start_one_ms_late_follows_expiry': simulate([e(1001, 'start')])['trace'] == [{'at_ms': 1000, 'reason': 'noinput'}, {'at_ms': 1001, 'reason': 'start'}],
        'start_cancels_noinput_not_recognition_timeout': simulate([e(500, 'start')], horizon_ms=5000)['state'] == {'phase': 'IN_INPUT', 'deadline_ms': None, 'noinput_count': 0, 'nomatch_count': 0, 'route_label': None},
        'result_requires_prior_start': all(rejects(lambda kind=kind: simulate([e(500, kind)])) for kind in ['match', 'nomatch']),
        'nomatch_retry_deadline_begins_at_result': simulate([e(100, 'start'), e(400, 'nomatch')], horizon_ms=1399)['state']['deadline_ms'] == 1400,
        'all_silence_stops_at_bounded_failure_limit': silence == {'state': {'phase': 'TERMINAL', 'deadline_ms': None, 'noinput_count': 3, 'nomatch_count': 0, 'route_label': 'fallback'}, 'trace': [{'at_ms': t, 'reason': 'noinput'} for t in [1000, 2000, 3000]]},
        'nomatch_and_noinput_counters_remain_distinct': simulate(events, max_failures=2)['state'] == {'phase': 'TERMINAL', 'deadline_ms': None, 'noinput_count': 1, 'nomatch_count': 1, 'route_label': 'fallback'},
        'equal_time_events_keep_supplied_order': simulate([e(1000, 'start'), e(1000, 'match')])['state']['route_label'] == 'matched' and rejects(lambda: simulate([e(1000, 'match'), e(1000, 'start')])),
        'terminal_does_not_process_later_valid_events': simulate([e(1, 'hangup'), e(2, 'match')])['trace'] == [{'at_ms': 1, 'reason': 'hangup'}],
        'hangup_at_deadline_precedes_timeout': simulate([e(1000, 'hangup')])['state'] == {'phase': 'TERMINAL', 'deadline_ms': None, 'noinput_count': 0, 'nomatch_count': 0, 'route_label': 'ended'},
        'parameter_types_and_bounds': all(rejects(lambda kw=kw: simulate([], **kw)) for kw in [{'timeout_ms': True}, {'timeout_ms': 0}, {'timeout_ms': 10001}, {'max_failures': 0}, {'max_failures': 5}, {'start_ms': -1}, {'start_ms': 100, 'horizon_ms': 99}, {'horizon_ms': 60001}]),
        'event_fields_time_types_and_horizon': all(rejects(lambda rows=rows: simulate(rows)) for rows in [[{'at_ms': 1, 'kind': 'start', 'extra': 0}], [{'at_ms': True, 'kind': 'start'}], [e(2501, 'start')], [e(-1, 'start')], [e(1.0, 'start')], [None]]),
        'event_order_count_and_unknown_kind': all(rejects(lambda rows=rows: simulate(rows)) for rows in [[e(2, 'start'), e(1, 'match')], [e(0, 'hangup')] * 33, [e(1, 'unknown')], (e(1, 'start'),), [e(1, 'hangup'), e(2, 'unknown')]]),
        'independent_closed_form_silence_oracle': all(oracle_cases),
        'input_preserved_and_repeat_is_deterministic': events == before and simulate(events) == mixed and rejects(lambda: simulate([e(1, 'start'), e(2, 'start')])),
    }
    assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'example_kind': 'Offline synthetic integer-ms IVR input-start policy; no recognizer or calls',
            'mixed_example': mixed, 'all_silence_example': silence,
            'independent_closed_form_cases_inside_one_group': len(oracle_cases), 'checks': checks}


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

در پاسخ دستی، timeout برابر ۱۰۰۰ است: start در ۱۰۰۰ مهلت اولیه را لغو می‌کند؛ nomatch در ۱۲۰۰ شمارندهٔ خودش را یک و deadline را ۲۲۰۰ می‌کند. در ۲۲۰۰، noinput رخ می‌دهد و deadline تازه ۳۲۰۰ می‌شود. بنابراین در افق ۲۵۰۰، هر شمارنده یک و حالت LISTENING است؛ route_label هنوز None می‌ماند.

برای سکوت کامل و سقف سه، زمان‌های ۱۰۰۰، ۲۰۰۰ و ۳۰۰۰ به fallback می‌رسند. مرجع مستقلِ این حالت از تقسیم صحیحِ مدت سپری‌شده بر timeout و محدودکردن به سقف استفاده می‌کند؛ حلقهٔ رویداد برنامه را دوباره نمی‌نویسد. نود ترکیبِ شروع، مدت و سقف در یک گروه مقایسه می‌شوند، کنار پاسخ دستیِ مسیر ترکیبی.

۵. محدودیت مدل و چهار پاسخ کاربردی

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

dataclasses و replace کمک می‌کنند رکورد حالت تازه بسازیم. frozen جلوی انتساب معمول به فیلد را می‌گیرد، اما اعتبارسنجی نوع یا تضمین امنیت نیست؛ کنترل ورودی را خود simulate انجام می‌دهد. همهٔ رکوردها پیش از پردازش اعتبارسنجی می‌شوند، حتی رکوردی که پس از پایان می‌آید؛ بعد از پایان فقط رویدادهای معتبر نادیده گرفته می‌شوند.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "example_kind": "Offline synthetic integer-ms IVR input-start policy; no recognizer or calls",
  "mixed_example": {
    "state": {
      "phase": "LISTENING",
      "deadline_ms": 3200,
      "noinput_count": 1,
      "nomatch_count": 1,
      "route_label": null
    },
    "trace": [
      {
        "at_ms": 1000,
        "reason": "start"
      },
      {
        "at_ms": 1200,
        "reason": "nomatch"
      },
      {
        "at_ms": 2200,
        "reason": "noinput"
      }
    ]
  },
  "all_silence_example": {
    "state": {
      "phase": "TERMINAL",
      "deadline_ms": null,
      "noinput_count": 3,
      "nomatch_count": 0,
      "route_label": "fallback"
    },
    "trace": [
      {
        "at_ms": 1000,
        "reason": "noinput"
      },
      {
        "at_ms": 2000,
        "reason": "noinput"
      },
      {
        "at_ms": 3000,
        "reason": "noinput"
      }
    ]
  },
  "independent_closed_form_cases_inside_one_group": 90,
  "checks": {
    "mixed_timeline_matches_literal_hand_answer": true,
    "before_deadline_has_no_failure": true,
    "deadline_without_input_expires_inclusively": true,
    "input_start_at_exact_deadline_wins": true,
    "input_start_one_ms_late_follows_expiry": true,
    "start_cancels_noinput_not_recognition_timeout": true,
    "result_requires_prior_start": true,
    "nomatch_retry_deadline_begins_at_result": true,
    "all_silence_stops_at_bounded_failure_limit": true,
    "nomatch_and_noinput_counters_remain_distinct": true,
    "equal_time_events_keep_supplied_order": true,
    "terminal_does_not_process_later_valid_events": true,
    "hangup_at_deadline_precedes_timeout": true,
    "parameter_types_and_bounds": true,
    "event_fields_time_types_and_horizon": true,
    "event_order_count_and_unknown_kind": true,
    "independent_closed_form_silence_oracle": true,
    "input_preserved_and_repeat_is_deterministic": true
  }
}

هجده گروه بررسی اجرا شده‌اند؛ نود حالتِ مرجع، گروه تازه شمرده نمی‌شوند. هیچ sleep، ساعت سیستم، شبکه، تشخیص آغاز گفتار یا شماره‌گیری وجود ندارد. هزار میلی‌ثانیه توصیهٔ عمومی برای تماس واقعی نیست. منابع در ۲۵ مهر ۱۴۰۵ خوانده شدند؛ توصیه‌نامهٔ مارس ۲۰۰۴، مستندات 3.14.8 و اجرای 3.14.5 جدا ثبت شده‌اند و این آموزش خبر تازه نیست.

پرسش اول: start دقیقاً در ۱۰۰۰ دیر است؟ پاسخ: طبق سیاست انتخاب‌شده خیر؛ پیش از انقضای برابر پردازش و timer لغو می‌شود. بدون این سیاست، آزمون مرزی جواب یکتا ندارد.

پرسش دوم: start در ۵۰۰ و نبود نتیجه تا ۵۰۰۰ یعنی noinput؟ پاسخ: خیر؛ آغاز دریافت ثبت شده و حالت IN_INPUT باقی می‌ماند. مهلت بازشناسی را باید جدا طراحی کرد.

پرسش سوم: در مسیر ترکیبی با سقف دو چه می‌شود؟ پاسخ: nomatch در ۱۲۰۰ و noinput در ۲۲۰۰ مجموع را دو می‌کنند؛ همان‌جا fallback و deadline تهی می‌شود.

پرسش چهارم: پس از hangup یک match با شکل معتبر اثر دارد؟ پاسخ: خیر؛ پایان حفظ می‌شود. اما نوع رویداد ناشناخته حتی پس از پایان، در اعتبارسنجی اولیه خطا می‌دهد؛ نادیده‌گرفتن، مجوزِ دادهٔ نامعتبر نیست.

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

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