Skip to content

[#202] Default STT model → large-v3-turbo-q5_0 (fresh installs only) - #207

Merged
realproject7 merged 3 commits into
mainfrom
task/202-default-turbo-q5
Aug 4, 2026
Merged

[#202] Default STT model → large-v3-turbo-q5_0 (fresh installs only)#207
realproject7 merged 3 commits into
mainfrom
task/202-default-turbo-q5

Conversation

@realproject7

@realproject7 realproject7 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #202

Switches the fresh-install default STT model to large-v3-turbo-q5_0 and exposes it in the picker. No existing install is moved.

EPIC Alignment

The migration decision (scope item 3)

Fresh installs get the new default; every existing install keeps what it was running.

The discriminator is the settings file, not the field:

situation path result
Fresh install — no settings.json load()AppSettings::default() large-v3-turbo-q5_0
Existing file, no sttModel (predates #110) serde field default → migrated_stt_model() small
Existing file, explicit choice respected verbatim unchanged
Existing file, hand-edited junk sanitized()migrated_stt_model() small
Existing file, corrupt / unreadable load()existing_install_default() small

The corrupt/unreadable row is RE1's finding (#1461), fixed in f7b4320: load() previously collapsed "no file" and "unusable file" into one AppSettings::default(), so a single corrupt byte silently moved a working install onto the new download — contradicting the contract stated right here. ErrorKind::NotFound is now the only branch treated as a fresh install.

Why absence is treated as "existing install", not "unset": serde only reaches that default when a file was read, so anything landing there has run before. Users with pre-#110 files never chose small — they were defaulted into it — and treating that as consent to a 547 MB download on next session start is precisely the silent switch #202 rules out. The clamp for junk values goes the same way for the same reason.

The trade-off, stated rather than buried: an existing user who never touched the setting stays on small until they pick the new model in the Settings sheet, where it appears with its size. Opt-in beats an unrequested download — but it does mean the accuracy win doesn't reach existing users automatically.

Self-Verification

  • Migration proven on Linux, not deferred to CI. livecap-app can't build here, so the serde surface (DEFAULT_MODEL, both default fns, the field attribute, Default, and sanitized) was extracted verbatim into an offline cargo harness with the real serde/serde_json and executed. All five rows of the table above printed and asserted:
    fresh install (no file)          -> large-v3-turbo-q5_0
    existing file, no sttModel       -> small
    existing file, chose small/medium/large-v3-turbo/large-v3-turbo-q5_0 -> respected
    existing file, junk value        -> small
    existing file, corrupt JSON      -> small
    
  • Seeded both failure modes this design exists to prevent, each confirmed to panic: switching the field attribute back to #[serde(default = "default_stt_model")] (the naive implementation) makes the absent-field row return large-v3-turbo-q5_0; restoring unwrap_or_default() in load() makes the corrupt-file row do the same. Both guards are load-bearing, not decorative.
  • cargo test -p livecap-core --lib62 passed (runs on Linux; covers model_filename/MODEL_NAMES handling of the quantized name).
  • pnpm test:app170 passed (169 + 1 new); rustfmt --check on settings.rs still shows the same 5 diffs as main after the fix; pnpm -r --filter './packages/*' test → archive 110, engine 270.
  • pnpm lint, pnpm typecheck (both configs) → exit 0. ./scripts/no-stub-gate.sh, ./scripts/color-guard.sh → pass.
  • rustfmt --check on settings.rs: 5 diffs, identical to the count on main — none introduced by this change (verified by diffing the findings against a stashed tree; one comment was reflowed to the file's trailing-comment style to keep it that way).
  • Not run: the app-crate tests (settings.rs's four migration assertions) execute only under app-macos. The harness above covers the same logic on the same inputs, but the authoritative run is CI.
  • Kill-list: clean — no new dependency (the harness is a scratch file, not committed), no TODO/FIXME, no caption content logged, no stub.

What changed

  • model.rs:33DEFAULT_MODELlarge-v3-turbo-q5_0, with the measured justification in the doc comment.
  • settings.rs — allow-list gains the model; new migrated_stt_model() + LEGACY_STT_MODEL; field default and the sanitize clamp both point at it; four migration assertions added.
  • app-settings.ts — picker entry Large v3 Turbo (compact) / ~547 MB; sanitizedSttModel's small fallback documented as deliberately not tracking the new default.
  • main.ts:209 — first-paint placeholder matches the fresh-install default (display-only; get_settings overrides it).
  • docs/CALIBRATION.md — recommendation superseded in place with a note saying what the earlier one said and why it changed; RTF table gains a q5_0 row.
  • test/stt-model.test.ts — list membership, the size string, and the migration-fallback rationale.

Deviations

  • The q5_0 RTF row publishes only the mean and cold load. [feat] Default STT model → large-v3-turbo-q5_0 (547 MB): full-turbo accuracy at near-small download and load time #202 supplies 0.31 / 0.29 s; per-fixture f1/f2/f3 values were never measured, so they are left blank with a note rather than back-filled. Inventing them would be the exact defect [#111] STT calibration: reproducible model bench + measured findings #199 spent four review rounds removing.
  • medium and large-v3-turbo remain selectable (scope item 5); nothing was removed from either list.
  • The new option is appended, not placed first, so no existing picker position shifts — the current selection is what's highlighted, so discoverability doesn't depend on order.
  • No device verification. The on-device feel check rides the next device session per the ticket's routing; nothing here required running a model.
  • No other deviation: no new dependencies, no floor changes, pointer_url/HF_REPO/SHA-256 untouched, no caption content logged.

+82 MB over `small` (547 vs 465) buys turbo-class accuracy, and cold load stays
at `small`'s level — 0.29 s, not the unquantized build's 6.3 s, which is the
objection that had ruled turbo out. RTF 0.31, no FallingBehind in any run.
Measurements are the operator's, from #111/docs/CALIBRATION.md.

Migration — the settings FILE is the discriminator, not the field. Anything
reaching serde came off disk, so it is an install that has run before and keeps
what it had; only a fresh install (no file → AppSettings::default()) takes the
new default. Files predating #110 have no sttModel at all, and those users never
chose `small` — they were defaulted into it — so treating "absent" as consent to
a 547 MB download would be exactly the silent switch the ticket rules out. The
sanitize clamp goes to the legacy model for the same reason: a hand-edited junk
value is no more consent than an absent one.

The trade-off is real and is stated in the code: an existing user who never
touched the setting stays on `small` until they pick the new model in the sheet,
where it appears with its size. Opt-in beats an unrequested download.

The webview mirror deliberately does NOT track the new default — sanitizedSttModel
only ever sees settings loaded from disk, so it mirrors migrated_stt_model, not
DEFAULT_MODEL.

CALIBRATION.md's recommendation is superseded in place rather than rewritten: the
original recommended the UNQUANTIZED build, and the note records why that was
right at the time and what changed. Its RTF row carries only the mean and cold
load the operator published — the per-fixture values were not measured, so they
are blank rather than back-filled.

Integrity untouched: no change to pointer_url, HF_REPO, or the SHA-256 check.
model_family() already strips the -q suffix, so the quantized build inherits the
large-v3 floors with no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

The documented migration says every existing install keeps its prior model, but the invalid-settings recovery path still routes an existing install to the new fresh-install default.

Checked (evidence)

  • Structural gate: PR body contains EPIC Alignment, Self-Verification, and Deviations sections.
  • Context: #202 requires fresh installs to get q5_0 while existing selections are preserved; #107's direction is evidence-backed default changes without destabilizing shipped behavior.
  • Verified migration paths in src-tauri/src/settings.rs:121-140, 193-201, 329-360.
  • Riskiest part: distinguishing a missing settings file from an unreadable existing file.
  • Kill-list: one correctness finding below.
  • CI: gh pr checks 207 -> color-guard/no-stub-gate pass; app-macos, packages-linux, release-invariants pending.

Findings

  • [blocking] A corrupt existing settings file silently becomes a fresh-install q5_0 configuration.
    • File: src-tauri/src/settings.rs:193-201
    • Why it fails: load() maps any read/parse failure to AppSettings::default(), whose stt_model is now large-v3-turbo-q5_0. An existing user whose settings.json is unreadable is therefore pushed into the 547 MB download, contradicting the PR's “every existing install keeps what it was running” migration contract and the explicit anti-silent-switch rationale.
    • Do instead: distinguish missing path from an existing-but-unreadable file; recover existing-file defaults with migrated_stt_model() (or preserve a safe legacy settings fallback) and add a test covering invalid JSON at an existing path.

Decision

The allow-list, fresh default, valid-file migration, documentation, and picker changes align, but the damaged-existing-file path violates the migration contract. Fix that path and re-request review after all CI checks complete.

RE1: load() fell back to AppSettings::default() on a file that exists but will
not parse, so a single corrupt byte silently switched a working install onto a
547 MB download — the exact outcome this migration exists to prevent, and a
direct contradiction of the contract I stated in the PR body.

load() now distinguishes the two cases it had collapsed: ErrorKind::NotFound is
a fresh install and takes the new default; a file that exists but cannot be read
or parsed takes existing_install_default() — defaults everywhere else, legacy
model. An unreadable file (permissions, I/O) goes the conservative way for the
same reason: something is on disk.

The damaged-file test now asserts the distinction in both directions rather than
just "some defaults": equal to existing_install_default(), NOT equal to
default(). Extended the offline serde harness to cover it, and seeded the old
behaviour back — the corrupt case returns q5_0 and the assertion panics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — REQUEST CHANGES

PR #207 (Closes #202) @ e640b00 — all 5 CI checks green. The migration design is right and well-documented, and both judgment calls are correct. One path through load() defeats the invariant the PR states, and it is the case @dev asked to have attacked.


Blocking: a corrupt settings file on an EXISTING install gets the 547 MB download

The PR's stated invariant (settings.rs:40-45) is:

"The discriminator is the settings FILE, not the field: load() only reaches this deserialization path when a file was read, so anything landing here is an install that has run before."

That holds for a file that parses. It does not hold for a file that doesn't. load() (settings.rs:195-202):

Ok(text) => serde_json::from_str::<AppSettings>(&text)
    .map(AppSettings::sanitized)
    .unwrap_or_default(),      // <-- parse failure lands HERE
Err(_)  => AppSettings::default(),

unwrap_or_default() returns AppSettings::default() — the fresh-install path — whose stt_model is default_stt_model()DEFAULT_MODELlarge-v3-turbo-q5_0. So a file that was read but is unparseable takes the new default.

Verified empirically, not by code read. I replicated load()'s exact shape with the real serde/serde_json and the same attributes, and ran the full matrix:

fresh install (no file)            -> large-v3-turbo-q5_0   <<< intended
existing file, no sttModel         -> small                 ✓
existing file, explicit small      -> small                 ✓
existing file, junk VALUE          -> small                 ✓
existing file, CORRUPT JSON        -> large-v3-turbo-q5_0   <<< 547 MB DOWNLOAD
existing file, truncated mid-write -> large-v3-turbo-q5_0   <<< 547 MB DOWNLOAD

Your four documented rows all behave exactly as claimed. The two you did not test are the ones that break.

Why this blocks rather than gets noted. It contradicts three things at once:

  1. Your own doc comment, which becomes false as written — a corrupt file was read and is an install that has run before, yet it does not land on the migration default.
  2. Your own junk-value rationale (settings.rs:155-159): "a garbage value is no more consent to a 547 MB download than an absent one." A garbage file is the same case and goes the opposite way. Handling a bad sttModel string carefully while a bad file silently upgrades is the inconsistency.
  3. [feat] Default STT model → large-v3-turbo-q5_0 (547 MB): full-turbo accuracy at near-small download and load time #202 scope item 3: "silently switching a working install to a 547 MB download on next session start is user-hostile." This path does precisely that.

Probability is low — writes are atomic (save_atomic, temp + rename). But the population that hand-edits settings.json is exactly the population with an existing install, and a hand-edit is at least as likely to break JSON syntax as to write a bad model name — which is the case you already guard.

Suggested fix (either shape; ~2 lines):

.unwrap_or_else(|_| serde_json::from_str::<AppSettings>("{}")
    .expect("empty object parses")
    .sanitized())

Parsing "{}" runs every field through its serde default, so stt_modelmigrated_stt_model()small. That is exactly the semantic you want: a file existed but told us nothing. Or explicitly: AppSettings { stt_model: migrated_stt_model(), ..Default::default() }.sanitized(). Please add the corrupt-file row to fresh_install_gets_the_new_default_existing_installs_keep_theirs — it is the row that would have caught this.


Checked (evidence) — everything else verified

The migration design is correct where it parses, and the file-not-field discriminator is the right idea. load()'s Err(_) arm gives a genuinely fresh install AppSettings::default() → the new default; a parsed file routes stt_model through the field-level #[serde(default = "migrated_stt_model")], which correctly overrides the struct-level #[serde(default)]. Confirmed in the harness: absent field → small, all four explicit values round-trip, unknown value clamps to small.

The clamp cannot strand a fresh install. sanitized() runs STT_MODELS.contains(), and large-v3-turbo-q5_0 was added to that list (settings.rs:77) — so the new default survives sanitization instead of clamping back to small. The existing DEFAULT_MODEL ∈ STT_MODELS assertion guards it, and the new STT_MODELS.contains(&LEGACY_STT_MODEL) assertion guards the mirror case. Both are the right invariants to have written down.

Rust and web stay aligned, and the TS fallback is deliberately not tracking DEFAULT_MODEL: sanitizedSttModel keeps returning "small" with a comment explaining it mirrors migrated_stt_model, not the fresh default, because it only ever sees settings loaded from disk. That is the correct asymmetry and easy to get wrong. main.ts:209's in-memory seed is the fresh-install value with get_settings overriding — consistent.

Scope holds: no floor changes (engine.rs untouched — model_family() already strips -q*, so the quantized build inherits the large-v3 floors with no edit, as the ticket predicted); integrity/SHA-pinning path untouched; no new dependencies; medium and large-v3-turbo remain selectable.

Gates: pnpm test:app 170 passed, both typechecks exit 0, pnpm lint exit 0, and all 5 CI green at e640b00 (app-macos, release-invariants, packages-linux, color-guard, no-stub-gate) — the authoritative run of the four settings.rs migration assertions.

Your two judgment calls — both right

  1. Superseding docs/CALIBRATION.md's recommendation in place, with a note saying what the old one said and why it changed, is correct and follows exactly the pattern [#111] STT calibration: reproducible model bench + measured findings #199 landed on. It also keeps the earlier objection (1.5 GB / 6 s cold load) visible as the reason the unquantized build lost, which is the useful part.
  2. Leaving the per-fixture RTF cells blank with a note is the right call and I'd have blocked the alternative. Back-filling unmeasured cells is precisely the defect [#111] STT calibration: reproducible model bench + measured findings #199 spent four rounds removing. Listing RTF 0.31 as the one axis where q5_0 is not best of the set is the same discipline.

On the trade-off you flagged for argument

Keeping untouched existing installs on small until they opt in is the right call: #202 names the silent switch as the thing to avoid, the user sees the new option with its size in the sheet, and the decision is reversible by them rather than by us. The residual — those users never learn the better model exists unless they open Settings — is a product follow-up, not a defect in this PR.

Fix the corrupt-file path and I will re-confirm the delta immediately.

(The shared bot token cannot file a formal GitHub change request, so this comment plus my chat message is the RE2 verdict of record.)

@re2 independently found the same corrupt-file gap @re1 did, and named a second
shape I had not listed: a settings write truncated mid-flight. It takes the same
parse-failure arm, so f7b4320 already fixed it — but "already covered by the
same branch" is a claim, and the point of this batch has been not shipping
those. Now it has its own row in the test and the harness, and it is the
realistic corruption: save_atomic makes hand-edits the rarer path, but a
truncated file is what an interrupted write leaves.

Kept existing_install_default() rather than @re2's suggested parse-of-"{}":
identical result for every field, but explicit about intent and with no
.expect() on a hardcoded literal in the load path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Epic Alignment: PASS

PR #207 implements #202's evidence-backed fresh-install default while preserving existing selections and the Rust/web allow-list and integrity contracts.

Checked (evidence)

  • Reviewed final delta f7b4320..2ef605a: src-tauri/src/settings.rs adds explicit truncated-write coverage for the conservative existing-install branch.
  • Verified migration source: missing path uses AppSettings::default() (q5_0); parse/read failures use existing_install_default() (legacy small); valid explicit choices remain accepted.
  • Verified docs and picker: docs/CALIBRATION.md publishes the measured q5_0 mean/cold-load row without inventing fixture values; app-settings.ts exposes ~547 MB and preserves all prior options.
  • Riskiest part: fresh-vs-existing settings discrimination, including corrupt and truncated files; the real app-macos suite covers these assertions.
  • Kill-list: scanned final changed ranges — clean.
  • CI: gh pr checks 207 -> all five checks pass (app-macos, packages-linux, release-invariants, color-guard, no-stub-gate).

Findings

None.

Decision

The migration guard is now explicit and tested in the authoritative app suite, the model/default and picker changes match #202, and all live CI checks pass.

@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE (at 2ef605a)

PR #207 (Closes #202) @ 2ef605a — all 5 CI checks green. This supersedes my REQUEST CHANGES at e640b00 (comment 5185140869). 6 files, +198/−41, no new dependencies.

The blocking finding is resolved — and the fix is broader than what I asked for

I flagged only the parse-failure arm. load() (settings.rs:214-224) now handles three cases distinctly, and the third is one neither reviewer named:

Ok(text) => …from_str().map(sanitized).unwrap_or_else(|_| existing_install_default()),
Err(e) if e.kind() == ErrorKind::NotFound => AppSettings::default(),   // genuinely fresh
Err(_) => AppSettings::existing_install_default(),                      // exists, unreadable

A file that exists but cannot be opened (permissions, I/O) is just as much an existing install as one that fails to parse. That is the same ENOENT-only discipline #192's folder sweep landed on, and it is the correct generalisation of the finding rather than a patch of the specific row I reported.

Re-verified empirically, not by code read. I rebuilt the harness against the new load() shape with real serde/serde_json and the same attributes, and ran the full matrix:

fresh install (NotFound)      -> large-v3-turbo-q5_0   (intended)
existing, unreadable (EACCES) -> small
existing, no sttModel         -> small
existing, explicit small      -> small
existing, explicit q5_0       -> large-v3-turbo-q5_0   (respected)
existing, junk VALUE          -> small
existing, CORRUPT JSON        -> small
existing, truncated mid-write -> small

Zero unrequested-download rows. #202 scope item 3 now holds on every path into load().

Checked (evidence)

1. The fix shape is sound, and its stated equivalence is verified. existing_install_default() (settings.rs:148-153) is Self { stt_model: migrated_stt_model(), ..Self::default() }. I checked the claim that this is identical to parsing "{}" two independent ways: (a) only two fields carry a field-level #[serde(default = …)]source_language, whose default function is the same one impl Default calls, and stt_model, which is exactly the field overridden here; (b) asserting existing_install_default() == serde_json::from_str::<AppSettings>("{}") returns true. Provably identical, so readability decides, and the named constructor states the intent without an .expect() on a literal in the load path.

2. The migration contract holds end to end. Fresh install (no file) → DEFAULT_MODELlarge-v3-turbo-q5_0; every explicit choice round-trips including small; absent field, junk value, corrupt file, truncated file and unreadable file all resolve to the legacy model. The field-level #[serde(default = "migrated_stt_model")] correctly overrides the container-level #[serde(default)].

3. The fresh default cannot be clamped away. sanitized() gates on STT_MODELS, and large-v3-turbo-q5_0 is in that list (settings.rs:77) — so a fresh install's default survives sanitization. The DEFAULT_MODEL ∈ STT_MODELS assertion and the new STT_MODELS.contains(&LEGACY_STT_MODEL) assertion guard both directions of that trap.

4. Rust and web stay aligned, with the correct asymmetry. sanitizedSttModel (app-settings.ts) deliberately keeps returning "small" and does not track DEFAULT_MODEL, because it only ever sees settings loaded from disk — it mirrors migrated_stt_model, not the fresh default. Documented in place. main.ts:209's in-memory seed is the fresh-install value with get_settings overriding. Picker entry carries the accurate ~547 MB and a clear label; medium and large-v3-turbo remain selectable per scope item 5.

5. Out-of-scope surfaces untouched. No floor changes — model_family() already strips the -q* suffix, so the quantized build inherits the large-v3 floors with no edit, exactly as the ticket predicted. Integrity/SHA-pinning path untouched. No new dependencies.

6. Gates. pnpm test:app 170 passed, both typechecks exit 0, pnpm lint exit 0. All 5 CI green at 2ef605aapp-macos 2m41s (the authoritative run of the migration assertions), release-invariants 1m55s, packages-linux 36s, color-guard, no-stub-gate.

The two judgment calls — both right

  1. Superseding docs/CALIBRATION.md's recommendation in place, with a note recording what the previous one said and why it changed, follows the pattern [#111] STT calibration: reproducible model bench + measured findings #199 landed on and keeps the 1.5 GB / 6 s objection visible as the reason the unquantized build lost.
  2. Leaving the per-fixture RTF cells blank with an explicit note is correct, and I would have blocked the alternative. Back-filling unmeasured cells is precisely the defect [#111] STT calibration: reproducible model bench + measured findings #199 spent four rounds removing. Naming RTF 0.31 as the one axis where q5_0 is not best of the set is the same discipline applied against its own recommendation.

On the migration trade-off, since it was offered for argument

Keeping untouched existing installs on small until they opt in is the right call: #202 names the silent switch as the thing to avoid, the new option is visible in the Settings sheet with its size, and the decision stays the user's. The residual — those users do not discover the better model unless they open Settings — is a product follow-up, not a defect in this PR.

(The shared bot token cannot file a formal GitHub approval, so this comment plus my chat message is the RE2 verdict of record.)

@realproject7
realproject7 merged commit 616c919 into main Aug 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Default STT model → large-v3-turbo-q5_0 (547 MB): full-turbo accuracy at near-small download and load time

2 participants