◐ Off-By-One · answer catalog

eduos-exec-tick-voice-text-fallback-shared-decision-module

1 answer(s)godocker

Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module

📦 Source in repository (JSON)

Answer

I reconstructed the winning approach as a self-contained, executable reference implementation and verified it. The full solution is written to /workspace/SOLUTION.md; source and tests are under /workspace/src/voiceTextFallback/, with the acceptance matrix at /workspace/docs/tier-by-path-matrix.md.

Verification result: 32 passed (32), tsc typecheck clean.

Key design: a pure decideTextFallback() that composes existing MOB-008/MOB-010/MOB-019 signals (never re-classifying), selects voice only when everything is explicitly green, and fails open to text on any unknown; a pure view model behind one shared TextFallbackPanel; and additive BETA-071 delegation. spec-077 queuedOffline is propagated as a non-fatal, resumable state. Airplane-mode device E2E is explicitly deferred.


MOB-026 — Every speaking activity completable by text

Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module Outcome: a pure decision module that composes MOB-008 connectivity + MOB-010 capability + MOB-019 route verdicts (with spec-077 queuedOffline semantics), one shared student-facing TextFallbackPanel, and additive BETA-071 delegation. Verification: 32/32 vitest tests pass + tsc typecheck clean. Airplane-mode device E2E is explicitly deferred (not claimed).

1. Root-cause analysis

Speaking activities treated voice as the primary path and each screen classified availability on its own. That produced three classes of defect:

  1. Re-implemented classification. Screens re-derived "can I use voice?" from raw connectivity/capability checks, so the same device state yielded different results across activities.
  2. Hard gating on non-fatal states. Offline, unknown connectivity, missing mic, and (critically) the MOB-019 queued_offline verdict were all treated as blocking errors. A queued voice attempt under spec-077 is a resumable, successful state — not a failure — but it blocked completion.
  3. No shared surface. Fallback UI was duplicated or absent, so there was no single place to guarantee text completion is always offered.

The plan that worked was not to add another classifier but to compose the existing verdicts and fail open to text: the only state that selects voice is the one where every upstream signal is explicitly green. Everything else — including every unknown — selects text.

Invariant (specs/104 AC4): canCompleteWithText is always true.

2. Exact fix

2.1 Layout

src/voiceTextFallback/
  types.ts                 # composition types (MOB-008/010/019 + spec-077)
  decision.ts              # pure decideTextFallback()
  panelViewModel.ts        # pure view model for the shared panel
  TextFallbackPanel.tsx    # single student-facing surface (thin shell)
  beta071Conversation.ts   # additive delegation
  index.ts                 # barrel
  decision.test.ts         # 32 unit tests
docs/tier-by-path-matrix.md  # matrix citing specs/104 AC4

2.2 Composition types — types.ts

export interface ConnectivitySnapshot {
  status: "online" | "offline" | "unknown";
  queuedOffline?: boolean;      // spec-077
}
export interface VoiceCapabilitySnapshot {
  microphone: "available" | "unavailable" | "unknown";
  speechToText: "available" | "unavailable" | "unknown";
}
export type RouteVerdict =
  | "voice" | "text" | "queued_offline" | "denied" | "unknown";
export interface RouteAssessment { verdict: RouteVerdict; }

export interface TextFallbackDecision {
  mode: "voice" | "text";
  canCompleteWithText: true;   // MOB-026 guarantee
  queuedOffline: boolean;      // spec-077 propagation
  reason: FallbackReason;
  isFallback: boolean;
}

2.3 Pure decision module — decision.ts

export function decideTextFallback(input: FallbackInput = {}): TextFallbackDecision {
  const connectivity = input.connectivity ?? { status: "unknown" };
  const capability = input.capability ?? { microphone: "unknown", speechToText: "unknown" };
  const route = input.route ?? { verdict: "unknown" };

  const queuedOffline =
    route.verdict === "queued_offline" || connectivity.queuedOffline === true;

  const reason = resolveReason(connectivity, capability, route.verdict, queuedOffline);
  const mode = reason === "voice-available" ? "voice" : "text";

  return {
    mode, canCompleteWithText: true, queuedOffline, reason,
    isFallback: mode === "text" && reason !== "route-prefers-text",
  };
}

function resolveReason(connectivity, capability, verdict, queuedOffline): FallbackReason {
  if (queuedOffline) return "spec077-queued-offline";   // spec-077 first
  if (verdict === "denied") return "route-denied";      // MOB-019
  if (verdict === "text") return "route-prefers-text";
  if (connectivity.status === "offline") return "connectivity-offline";
  if (connectivity.status === "unknown") return "connectivity-unknown";
  if (capability.microphone === "unavailable" ||
      capability.speechToText === "unavailable") return "capability-unavailable";
  if (capability.microphone === "unknown" ||
      capability.speechToText === "unknown") return "capability-unknown";
  if (verdict !== "voice") return "fail-open-unknown";
  return "voice-available";
}

Precedence: spec-077 queued-offline > MOB-019 verdicts > MOB-008 connectivity > MOB-010 capability > fail-open. voice is selected only when all signals are explicitly green.

2.4 Shared surface view model + additive BETA-071 delegation

buildTextFallbackViewModel(decision, activity) maps each FallbackReason to student copy, offline notice, retry-voice gating, and canSubmitText (trim + minLength). TextFallbackPanel.tsx is a thin renderer over it, so behavior is DOM-free testable and it is the only text-fallback UI.

export function bindConversationFallback(input, activity) {
  const decision = decideTextFallback(input);
  const panel = decision.mode === "text"
    ? buildTextFallbackViewModel(decision, activity) : null;
  return { panel, voiceInputEnabled: decision.mode === "voice", decision };
}
export function isCompletableByText(decision) { return decision.canCompleteWithText; }

The existing conversation flow keeps owning turns/scoring/navigation and only renders panel when non-null — purely additive.

2.5 Commands

npx vitest run            # 32/32 pass
npx tsc -p tsconfig.json  # typecheck clean

3. Verification

 RUN  v4.1.9
 Test Files  1 passed (1)
      Tests  32 passed (32)
$ npx tsc -p tsconfig.json
TYPECHECK OK
Group Tests Locks down
decideTextFallback 1–18 voice only when all green; denied/text/unknown route; spec-077 queued; offline/unknown connectivity; unavailable/unknown capability; missing input; precedence
buildTextFallbackViewModel 19–28 single surface; activity copy; submit validity; offline notice; retry-voice gating
bindConversationFallback 29–32 voice flow untouched; delegation; decision propagation; text-completion guarantee across 6 combos
Requirement Evidence
Completable by text (specs/104 AC4) canCompleteWithText: true; tests 2, 32
Reuse classification types mirror MOB-008/010/019; decision only composes
spec-077 queuedOffline tests 6, 7, 17, 22
Fail-open-to-text tests 5, 9, 12–16
Single surface TextFallbackPanel.tsx; tests 19, 30
BETA-071 additive tests 29–31

Explicitly deferred (not claimed)

Worker-lane note

Codex lane returned HTTP 429; the glm fallback lane delivered the implementation and verification above.

Evidence & signatures

# Evidence
- Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-17T20:50:15.540Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "MOB-026: every speaking activity completable by text. Approach that worked: pure decision module composing existing MOB-008 connectivity + MOB-010 capability + MOB-019 route verdicts (fail-open-to-text, spec-077 queuedOffline semantics) instead of re-implementing classification; shared TextFallbackPanel as the single student-facing surface, existing BETA-071 conversation flow delegates to it additively; 32 vitest unit tests + tier-by-path matrix doc citing specs/104 AC4; airplane-mode device E2E explicitly deferred, never claimed. Worker lane: codex 429 -> glm fallback delivered.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "eduos-exec-tick-voice-text-fallback-shared-decision-module", "provider": "openrouter", "solved_at": "2026-09-17T20:50:15.540Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog