fix(config): fall back to defaults on malformed numeric config values - #686
Conversation
recall.load_config and capture.load_config passed YAML-sourced numeric fields (max_chars, min_observations, dedup_window_seconds) straight through bare int()/float(), raising ValueError on a config typo like max_chars: "12,000" instead of degrading to the default the way the same modules' enabled boolean already does via coerce_bool. recall.load_config backs the SessionStart hook with no exception handling around the call, so one bad numeric value took down recall injection on every new session; capture.load_config backs session-split capture and codex rollout ingestion the same way. compile.py already implemented this fail-soft contract for its own numeric fields via a local _coerce(value, default, cast) helper. promote it into the shared config_coerce.py module as coerce_numeric, alongside the existing coerce_bool, and use it in recall.py and capture.py's load_config functions, removing the now-redundant local copy in compile.py. new tests confirm a malformed numeric value degrades to the default in both recall and capture rather than raising. this exact defect and fix were previously submitted as vouchdev#488 (CodeRabbit-reviewed, no substantive objections), but that PR was closed unmerged for going stale against a fast-moving test branch, not for anything wrong with the change; the maintainer's closing comment explicitly invited a fresh PR. Fixes vouchdev#685
…coercion # Conflicts: # CHANGELOG.md
|
the consolidation is the right shape: the two things worth a look, neither blocking:
and the prior-art note on #488 is useful context and matches what i see — the boolean half landed separately since, the numeric gap did not. |
a6c6862 (vouchdev#686) fixed capture.load_config's min_observations and dedup_window_seconds to fall back to their defaults on a malformed config value via the new coerce_numeric() helper, instead of raising ValueError straight out of load_config. 47eaf56 (vouchdev#645, realtime opt-in) branched off the pre-fix capture.py and reintroduced the bare int()/float() calls when it merged into test - the coerce_numeric import survived (nothing else referenced it), but the two call sites it fed didn't, silently reverting the fix and leaving test_load_config_malformed_numeric_falls_back red on `test` HEAD itself, currently failing this PR's CI via ruff's unused-import gate. restore the coerce_numeric() calls, matching recall.load_config's still-intact equivalent. unrelated to this PR's own change (fsck delete-proposal handling); needed only to get CI green on top of a currently-broken `test`.
a6c6862 (vouchdev#686) fixed capture.load_config's min_observations and dedup_window_seconds to fall back to their defaults on a malformed config value via the coerce_numeric() helper, instead of raising ValueError straight out of load_config. 47eaf56 (vouchdev#645, realtime opt-in) branched off the pre-fix capture.py and reintroduced the bare int()/float() calls when it merged into test - the coerce_numeric import survived (nothing else referenced it), but the two call sites it fed didn't, silently reverting the fix and breaking ruff's unused-import gate for every PR built on top of `test`. restore the coerce_numeric() calls, matching recall.load_config's still-intact equivalent. unrelated to this PR's own change (extract.py segmentation); needed only to get CI green on top of a currently-broken `test`.
a6c6862 (vouchdev#686) fixed capture.load_config's min_observations and dedup_window_seconds to fall back to their defaults on a malformed config value via the coerce_numeric() helper, instead of raising ValueError straight out of load_config. 47eaf56 (vouchdev#645, realtime opt-in) branched off the pre-fix capture.py and reintroduced the bare int()/float() calls when it merged into test - the coerce_numeric import survived (nothing else referenced it), but the two call sites it fed didn't, silently reverting the fix and breaking ruff's unused-import gate for every PR built on top of `test`. restore the coerce_numeric() calls, matching recall.load_config's still-intact equivalent. unrelated to this PR's own change (graph.py edge leak); needed only to get CI green on top of a currently-broken `test`.
a6c6862 (vouchdev#686) fixed capture.load_config's min_observations and dedup_window_seconds to fall back to their defaults on a malformed config value via the coerce_numeric() helper, instead of raising ValueError straight out of load_config. 47eaf56 (vouchdev#645, realtime opt-in) branched off the pre-fix capture.py and reintroduced the bare int()/float() calls when it merged into test - the coerce_numeric import survived (nothing else referenced it), but the two call sites it fed didn't, silently reverting the fix and breaking ruff's unused-import gate for every PR built on top of `test`. restore the coerce_numeric() calls, matching recall.load_config's still-intact equivalent. unrelated to this PR's own change (experts.py viewer scoping); needed only to get CI green on top of a currently-broken `test`.
vouchdev#686 added coerce_numeric and routed capture.py's two boolean fields through coerce_bool, but left min_observations and dedup_window_seconds on bare int()/float(). a typo'd value raised out of load_config instead of falling back to the default, which is the exact case the helper's own docstring cites (`min_observations: "three"`), and the resulting unused import tripped ruff F401. surfaced by merging test into this branch: the branch-push workflows on test don't run pytest/mypy/ruff, so the gate never ran on the merge that landed it. Co-authored-by: Cursor <cursoragent@cursor.com>
#686 added coerce_numeric and routed capture.py's two boolean fields through coerce_bool, but left min_observations and dedup_window_seconds on bare int()/float(). a typo'd value raised out of load_config instead of falling back to the default, which is the exact case the helper's own docstring cites (`min_observations: "three"`), and the resulting unused import tripped ruff F401. surfaced by merging test into this branch: the branch-push workflows on test don't run pytest/mypy/ruff, so the gate never ran on the merge that landed it. Co-authored-by: Cursor <cursoragent@cursor.com>
What changed
recall.load_config()'smax_charsandcapture.load_config()'smin_observations/dedup_window_secondsnow go through a new sharedcoerce_numeric()helper inconfig_coerce.pyinstead of bareint()/float()calls.compile.py's local_coerce()— which alreadyimplemented this exact fail-soft pattern for its own numeric fields — is
removed in favor of the shared helper.
Why
Both
load_config()functions promise "fall back to defaults" onmalformed config, and their boolean
enabledfield already honors thatvia
coerce_bool, but their numeric fields didn't: a config typo likemax_chars: "12,000"raisedValueErrorstraight out ofload_config.recall.load_configbacks the SessionStart hook with no exceptionhandling around the call, so one bad numeric value crashed recall-digest
injection on every new session;
capture.load_configbacks session-splitcapture and codex rollout ingestion the same way.
Confirmed with a repro:
recall.load_config(store)on a KB withmax_chars: "12,000"raisesValueError: invalid literal for int() with base 10: '12,000'instead of falling back toDEFAULT_MAX_CHARS.Fixes #685
What might break
Nothing for users with an existing
.vouch/directory — no on-diskshape,
kb.*method, or object model change. Behaviorally: a malformednumeric config value now degrades to the default instead of raising —
strictly the documented, intended contract. A valid numeric value parses
identically to before.
VEP
Not applicable — no object model,
kb.*method, on-disk layout, bundleformat, or audit-log shape change. A config-parsing robustness fix, plus
consolidating a helper that already existed in one module into the
shared one two other modules already use for the boolean case.
Prior art
This exact defect and fix were previously submitted as #488
(
fix(config): fall back to defaults on malformed numeric config values, CodeRabbit-reviewed with no substantive objections) but closedunmerged on 2026-07-29 purely for going stale against
test(
src/vouch/capture.pyandsrc/vouch/recall.pyconflicts) — themaintainer's closing comment explicitly said this wasn't a judgment on
the change. Re-verified independently against current
testHEAD: onlythe boolean coercion was separately fixed since; the numeric gap is
still live. This PR reintroduces the fix, freshly rebased, structured as
a shared
coerce_numeric()(matching the existingcoerce_bool()pattern) rather than three separate local copies.
Tests
make check-equivalent: ruff clean (src+tests); mypyclean on all four changed source files; 83 tests across
tests/test_recall.py,tests/test_capture.py, and the relevanttests/test_compile.pycase pass — two pre-existing, unrelatedfailures in
test_compile.py(test_jsonl_kb_compile_files_ proposals,test_two_phase_compile_drafts_planned_pages) are aWindows-only stub-LLM
python3invocation issue, confirmedidentical via
git stashcomparison against unmodifiedtestHEAD; CI runs on Linux
test_load_config_malformed_max_chars_falls_back(recall),test_load_config_malformed_numeric_falls_back(capture);compile.py's existingtest_load_config_bad_values_fall_back_to_defaultscontinues to pass unchanged against the now-shared helper
CHANGELOG.mdupdated under## [Unreleased]