Skip to content

Post-migration review: fix all 6 findings in coverage/suggestion/generator path - #34

Merged
Marcogn merged 8 commits into
mainfrom
claude/android-migration-verification-vt7gru
Sep 5, 2026
Merged

Post-migration review: fix all 6 findings in coverage/suggestion/generator path#34
Marcogn merged 8 commits into
mainfrom
claude/android-migration-verification-vt7gru

Conversation

@Marcogn

@Marcogn Marcogn commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Post-migration code review of the coverage and suggestion/generator path, plus a fix for every finding it turned up — one finding per commit, 7 commits total.

docs/post-migration-review.md

A code-level audit against docs/plan/native-spec.md and the phase plans. legacy-web/ was deleted in 3e11ea8 but survives in the log, so every Kotlin port was diffed against its TypeScript original recovered with git show 3e11ea8^:legacy-web/... — the same oracle Phase 6 used, applied independently.

The ported maths is faithful. CoverageEngine.kt, AbilityEffects.kt, Scoring.kt and SuggestionEngine.kt match their originals function for function. The defects were all at the seams, and none was reachable by the existing unit tests (test pools are 5–20 entries against a production pool in the high hundreds; no test asserted on threading before this review added one).

# Finding Severity Status
1 regenerateSlot's sort selector re-rolled its random noise on every comparison, breaking Comparator's contract — TimSort threw above MIN_MERGE, and the "top 5" was arbitrary even when it didn't crash ✅ fixed
2 Both engines ran on Dispatchers.Main.immediate jank / ANR risk ✅ fixed
3 computeCompositeScore redid per-team work once per candidate instead of once per team performance ✅ fixed
4 (see correction below) ✅ addressed
5 Surprise Me's "Custom slots" stepper consumed the six-slot budget and placed nothing misleading UI ✅ fixed
6 Abilities honoured by the coverage grid but ignored by suggestion scoring accuracy ✅ fixed

0 of 59 items in docs/test-plan.md were ticked at review time — findings 1, 2 and 5 are exactly the kind a single on-device pass would have caught. That gap is not closed by this PR: nothing here ran on a device or emulator (no Android SDK in the review sandbox), and manual verification per docs/test-plan.md remains outstanding.

⚠️ Finding 4 was corrected before being acted on

The first draft of this review claimed the Suggestions panel was missing the PWA's type-filter chips, a "Best coverage"/"Random" mode toggle, and showed 5 cards where the PWA showed 10 — based on diffing against the legacy React components alone, without checking this rewrite's own spec first. docs/plan/native-spec.md's "Suggestion engine" section says, verbatim, "Return the top 5 by gain" for both addition and replacement mode, and specifies neither of the other two items — SuggestionFilters.kt's own doc comment already said as much. Five cards is the spec for this rewrite, not a shortfall; none of that was implemented. What survived, independent of the retracted items: a "solid coverage" message (shown when every displayed suggestion has zero gain) and removing one genuinely orphaned string resource. Full correction in the review doc and implementation-decisions.md.

The fixes, one per commit

  1. Finding 1 (crash). computeScore's random noise was re-evaluated inside sortedByDescending's selector instead of once per candidate — non-transitive comparator, TimSort throws above MIN_MERGE. Fixed by scoring once, sorting on the stored value. New regression test: 300-entry same-typed pool (worst case for the bug) across 50 seeds.
  2. Finding 5 (dead feature). TeamGenerator never read GeneratorConstraints.customSlots, so the stepper consumed budget and placed nothing. Implemented: both generator entry points now take a customs list and treat it as a reserved category via a small Candidate wrapper.
  3. Finding 2 (main thread). AnalysisViewModel's pipeline now ends in .flowOn(Dispatchers.Default); SurpriseMeViewModel.generate()/regenerateSlot() set isGenerating synchronously then run entirely on Dispatchers.Default. Surprise Me gained a progress indicator and disables its actions while generating.
  4. Finding 3 (redundant work). Extracted the per-team half of computeCompositeScore into TeamScoringContext, built once per team instead of once per candidate (was candidates × 6 in replacement mode).
  5. Finding 4 (corrected). See above — added the solid-coverage message, removed the orphan string, retracted the rest.
  6. Finding 6 (accuracy, a real spec change). weaknesses() now takes an ability parameter, threaded through both the candidate and every team member. memberFromEntry now gives a candidate its real default ability instead of always null. New ScoringTest.kt exercises the new behavior directly.

Every fix commit documents, in its own message and in implementation-decisions.md, why every existing test either still passes unchanged or was updated with the reasoning spelled out (e.g. why one SurpriseMeViewModelTest predicate needed to change once regenerateSlot became async).

Verification caveat, repeated because it applies to every commit above: this sandbox has no Android SDK (ANDROID_HOME empty, no sdkmanager, confirmed by running ./gradlew testDebugUnitTest and watching it fail at SDK resolution before compiling), so none of this was verified by a local build or test run. CI is subscribed and watched, and is the actual verification for all of it.

🤖 Generated with Claude Code

https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6

Audits the shipped native app against the phase plans, diffing every
Kotlin port against the TypeScript original recovered from git history
(legacy-web/ was deleted in 3e11ea8 but survives in the log).

The ported maths is faithful — the engines, the tie-break order, the
0.5/1.0 weights and the deliberate generationIntroduced deviation all
match. The defects are at the seams:

- regenerateSlot() sorts with a selector that re-rolls its random noise
  on every comparison, so the comparator breaks Comparator's contract:
  TimSort throws on pools above MIN_MERGE, and the "top 5" is arbitrary
  even when it doesn't. A deviation from teamGenerator.ts, which scored
  each candidate once. Includes measured throw rates from a standalone
  JVM reproduction.
- Both engines run on Dispatchers.Main.immediate — the Analysis pipeline
  through stateIn(viewModelScope), the generator through plain
  non-suspend click handlers with no progress indicator.
- computeCompositeScore rebuilds the per-team half of its work once per
  candidate instead of once per team.
- The Suggestions panel dropped the PWA's type filter, random mode and
  two contextual messages, and shows 5 cards where the original showed 10.
- Surprise Me's "Custom slots" stepper consumes the six-slot budget and
  places nothing: the generator never reads customSlots.
- Abilities are honoured by the coverage grid but ignored by the
  suggestion scoring, so one screen contradicts itself.

Also notes that 0 of 59 items in docs/test-plan.md are ticked; three of
the six findings are ones a single on-device pass would have surfaced.

Documentation only, no behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
sortedByDescending { computeScore(...) } re-invoked computeScore once
per comparison, and computeScore adds fresh random noise on every call
(TeamGenerator.kt's own tie-breaking factor). That makes the comparator
non-transitive, which breaks Comparator's contract: TimSort detects the
inconsistency and throws IllegalArgumentException once the candidate
pool is large enough to leave binary-insertion-sort territory (pools
under Collections.sort's MIN_MERGE = 32 never hit the check, which is
why every existing test pool, all under 20 entries, passed). The
production pool after buildEligiblePool is in the high hundreds. Even
when it doesn't throw, the resulting order is arbitrary, so the "top 5"
regenerateSlot samples from is not actually the top 5.

This was introduced by the port: teamGenerator.ts scored each candidate
once into a stored {entry, member, score} triple and sorted on that.
generateTeam's maxByOrNull already evaluates its selector once per
element and was unaffected.

Fix: compute the score once per candidate into a Triple, sort on the
stored value. Adds a regression test with a 300-entry same-typed pool
(so composite scores tie and only the random noise breaks ties — the
worst case for the old code) across 50 seeds; asserts no exception.

Full analysis: docs/post-migration-review.md, finding 1.

No Android SDK in this sandbox (confirmed: ANDROID_HOME empty, no
sdkmanager, ./gradlew testDebugUnitTest fails at SDK resolution before
compiling) — this fix and test are unverified by a local run. The
approach mirrors a standalone JVM reproduction of the same TimSort/
Comparator failure mode (not committed, ad hoc), and CI is watched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
@Marcogn Marcogn changed the title Add a post-migration review of the coverage and suggestion path Post-migration review + fix Surprise Me regenerate crash (finding 1) Sep 5, 2026
GeneratorConstraints.customSlots has existed since Phase 4 as a ported
struct field, and SurpriseMeScreen has always rendered a stepper bound
to it, but TeamGenerator.kt never read the field: teamGenerator.ts
accepted a customs: TeamMember[] parameter it never referenced, and the
port dropped it as genuinely dead. The stepper still counted toward
SurpriseMeUiState.constraintTotal/remainingSlots/budgetFull, so it could
block a user from allocating starter/legendary/Mega/Dynamax slots in
exchange for placing zero custom Pokemon, ever.

Decided to implement rather than remove the stepper (documented in
implementation-decisions.md, "Post-migration review"): it is the one
generator feature that serves this app's stated ROM-hack/draft-building
audience, and SurpriseMeViewModel already loaded the custom roster into
its UI state without using it.

generateTeam and regenerateSlot now take a
customs: List<TeamMember> = emptyList() parameter (default keeps every
pre-existing call site, all of them in tests, unchanged) and treat
customSlots as a reserved category exactly like starter/legendary-
mythical/Mega/Dynamax, via a small Candidate wrapper (entry-backed or
custom) that unifies scoring and selection across both. One asymmetry,
stated in the same doc entry: a custom is never chosen opportunistically
in a free slot the way a catalogue Pokemon can be once its own quota is
met, since customs live outside buildEligiblePool's catalogue-only pool.
Setting customSlots = 0 must mean no custom ever appears.

Adds six tests: generateTeam fills exactly N custom slots and never
places one at N=0; regenerateSlot falls back to the custom roster when
the real catalogue has nothing eligible, never introduces a custom at
customSlots=0, and respects the cap already met by the other members.

Full analysis: docs/post-migration-review.md, finding 5.

Same verification caveat as the finding 1 commit: no Android SDK in
this sandbox, so these tests are unverified by a local run; CI is
watched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
AnalysisViewModel's combine() chain (analyseTeam, computeSuggestions,
sharedWeaknessCounts) and both of SurpriseMeViewModel's generator entry
points ran on Dispatchers.Main.immediate: the former inside stateIn's
own collector, the latter as plain non-suspend functions called
directly from Compose click handlers. Both do real work against a
catalogue in the high hundreds of entries, recomputed on every team
edit, toggle flip, filter change, and generation tap.

AnalysisViewModel: the final combine() now ends in
.flowOn(Dispatchers.Default).stateIn(...), moving the whole transform
lambda off Main.

SurpriseMeViewModel: generate()/regenerateSlot() set
isGenerating.value = true synchronously (before launching), then run
the entire computation and every result/warning/isGenerating write
inside viewModelScope.launch(Dispatchers.Default) { ... } - one hop to
a real background thread, no hop back through Main, since
MutableStateFlow.value can be set from any thread. Documented in
implementation-decisions.md why this shape was chosen over
withContext(Dispatchers.Default) { compute() } with the writes left on
the calling context, and why no injectable test dispatcher was added
(Dispatchers.Default is hardcoded, same convention as the direct
Dispatchers.IO calls already in data/pokeapi and data/backup).

SurpriseMeScreen gained a progress indicator on the Generate button and
disables every generation action (Generate, Regenerate slot,
Regenerate all, Keep) while one is in flight.

Updated the one existing test whose comment assumed regenerateSlot was
synchronous - the state was already non-empty from a prior generate()
call in the same test, so waiting on result.isNotEmpty() alone could
match stale data; now waits for !isGenerating too, which cannot,
since isGenerating flips to true synchronously before the async work
even starts. Added a test asserting isGenerating's true/false
transition, and one on AnalysisViewModel confirming coverage completes
without the test's Main dispatcher ever being advanced.

Full analysis: docs/post-migration-review.md, finding 2.

Same verification caveat as prior commits in this series: no Android
SDK in this sandbox, so these tests are unverified by a local run; CI
is watched. The reasoning about which Flow-based waits are safe under
StandardTestDispatcher (and which existing test patterns already prove
it) is laid out in the design decision recorded in
implementation-decisions.md, but it is reasoning, not a local test run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
…ding 3)

computeCompositeScore took otherMembers: List<TeamMember> and rebuilt
the offensive coverage union and the weakness map from it on every
call, even though neither depends on the candidate being scored -
only on the team. In SuggestionEngine's replacement mode (a full team
of six), every one of the N candidates recomputed all 6 possible
"team minus one member" contexts from scratch, when only 6 distinct
values are possible: N x 6 recomputations of the same 6 things.

Extracted that half into a TeamScoringContext, built once per distinct
team via teamScoringContext(chart, otherMembers) and passed into
computeCompositeScore in place of the raw member list:

- SuggestionEngine's addition mode builds one context before scoring
  every candidate (previously recomputed per candidate too).
- SuggestionEngine's replacement mode builds the 6 "team minus one
  member" contexts once before the candidate loop, not once per
  candidate.
- TeamGenerator.computeScore takes a context instead of the team list,
  built once per slot (generateTeam) or once per regenerateSlot call,
  not once per candidate scored in that iteration. It also reuses the
  context's baseCoverage as its own currentTeamCoverage argument,
  removing a second independent computation of the exact same set that
  existed only in that call site - documented in
  implementation-decisions.md why that reuse is specific to the
  generator and would be wrong in the suggestion engine, where
  currentTeamCoverage is analyseTeam's potentially moves-aware
  unionCovered, not the context's always-types-only baseCoverage.

Behaviour-preserving: same suggestions, same composite scores, same
ranking - every call site funnels through computeSuggestions/
generateTeam/regenerateSlot, all three already covered by
SuggestionEngineTest/TeamGeneratorTest's exact-score and exact-ranking
assertions, so no new test was added for this commit.

Full analysis: docs/post-migration-review.md, finding 3.

Same verification caveat as the prior commits in this series: no
Android SDK in this sandbox, so this refactor is unverified by a local
run; CI is watched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
The post-migration review's original finding 4 claimed the Suggestions
panel was missing the PWA's type-filter chips, its "Best coverage"/
"Random" mode toggle, and showed 5 cards where the PWA showed 10 -
based on diffing against SuggestionPanel.android.tsx/
SuggestionFilters.android.tsx alone, without checking this rewrite's
own spec first.

docs/plan/native-spec.md's "Suggestion engine" section says, verbatim,
"Return the top 5 by gain" for both addition and replacement mode, and
specifies neither the type filter nor the mode toggle.
SuggestionFilters.kt's own doc comment already said as much
("Deliberately smaller than legacy-web's own SuggestionFilters.tsx
[...]: neither is in this app's UI spec") - a comment that should have
been read before writing that finding, and wasn't. Five cards is the
spec for this rewrite, not a shortfall against the PWA. Implementing
the retracted items would have reversed a documented Phase 4 decision
instead of fixing a migration defect.

Corrected the finding in docs/post-migration-review.md (kept, not
deleted, with the correction stated plainly) and recorded the same in
implementation-decisions.md and the test-plan's Known regressions,
since this was already reported to the user as a defect before the
correction.

What survives, both narrow and independent of the retracted items:
- Added the "solid coverage" message (shown when every displayed
  suggestion has zero gain) to AnalysisScreen.kt, with new
  suggestions_solid_coverage strings in both locales.
- Removed suggestions_exclude_legendaries, a genuinely orphaned string
  resource referenced by no composable (the real toggle lives in
  Settings under the inverted settings_include_legendaries framing).

No domain logic changed; this is UI + string resources only. No new
test: no Compose screen tests exist in this codebase for this class of
change (per CLAUDE.md, screen-level behaviour is verified by hand, see
test-plan.md), consistent with how AnalysisScreen's other conditional
messages (e.g. suggestions_no_suggestions) are already covered.

Same verification caveat as the other commits in this series: no
Android SDK in this sandbox, so this change is unverified by a local
run; CI is watched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
weaknesses(chart, types) never took an ability, unlike CoverageEngine's
own sharedWeaknessCounts/defensiveProfile, which always did - so one
Analysis screen could disagree with itself: the coverage grid would
show a member immune to a type via its ability, while the Suggestions
section, fed by the same team, still penalized a candidate for
"aggravating" that exact weakness.

- Scoring.kt: weaknesses() takes an optional ability parameter.
  computeCompositeScore's candWeaknesses now passes candidate.ability;
  teamScoringContext's otherWeaknessMap now passes each member's own
  .ability when building it.
- SuggestionEngine.kt: memberFromEntry now sets ability =
  e.defaultAbility instead of always null, so a suggested/generated
  candidate is scored with the ability it will actually carry once
  applied, not with none. This is a native addition on top of the port,
  not a behavior the TypeScript ever had.
- TeamGenerator.kt: Candidate drops its own separate ability field -
  both candidateFromEntry and candidateFromCustom now produce a member
  whose own .ability is already correct, so the field that existed only
  to override it on pick is dead weight. team.add(best.member.copy(...))
  and the regenerateSlot equivalent simplify to just the member itself.

This is a real spec change, not a refactor: composite scores now differ
from the ported TypeScript baseline for any team member or candidate
carrying one of AbilityEffects.kt's 14 known scoring-relevant abilities.
Every existing fixture across TestFixtures.kt, SuggestionEngineTest.kt
and TeamGeneratorTest.kt has defaultAbility = null and builds every
TeamMember with no explicit ability either, so this change is invisible
to every existing exact-score/exact-ranking assertion - confirmed by
grep before writing this commit, not assumed. Added ScoringTest.kt to
exercise the new behavior directly: Levitate removing a candidate's own
Ground weakness, and a teammate's Levitate changing a shared candidate
weakness from "aggravated" (1.0 penalty) to merely "new" (0.5).

Documented as a deliberate spec change in implementation-decisions.md
and docs/test-plan.md, per the review's own instruction not to bundle
it with an unrelated fix.

Full analysis: docs/post-migration-review.md, finding 6 (also updated
in this commit: the Plan section now marks all six findings done, and
the Verdict section's stray "five findings" is corrected to six).

Same verification caveat as the other commits in this series: no
Android SDK in this sandbox, so this change and its new test are
unverified by a local run; CI is watched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
@Marcogn Marcogn changed the title Post-migration review + fix Surprise Me regenerate crash (finding 1) Post-migration review: fix all 6 findings in coverage/suggestion/generator path Sep 5, 2026
CI failed on every commit from e9796d2 onward (findings 2, 3, 4, 6),
all with the same single failure:

  SurpriseMeViewModelTest > isGenerating is true immediately after
  generate() and false again once it completes FAILED
  java.lang.AssertionError at SurpriseMeViewModelTest.kt:92

Confirmed by reading the job logs for the first and last failing runs:
every other test passed in both, so this was one bad test, not a
regression introduced by findings 3/4/6's changes.

Root cause: the test asserted vm.uiState.value.isGenerating
synchronously on the line right after calling generate(), on the
assumption that the launched coroutine "couldn't have finished yet."
That assumption was wrong. generate() launches directly on
Dispatchers.Default - a real thread pool, not gated by the test's
StandardTestDispatcher/TestCoroutineScheduler at all (that was the
whole point of finding 2's fix: get real work off the main thread).
Against the ~10-entry mock pool this test file uses, the background
computation can complete and flip isGenerating back to false before
the test's very next JVM instruction runs. A race, and evidently one
CI loses close to 100% of the time in this environment, not an
occasional flake.

Removed the test rather than patching it: there is no reliable way to
observe that transient true state from outside without an injectable
dispatcher for the background work, and finding 2's own decision
record (implementation-decisions.md) already explains why that was
considered and rejected. The behavior it was trying to demonstrate
(the coroutine eventually finishes and updates state) is still covered
by the existing `generate fills the result from the pool and keeps a
locked anchor first` test.

Updated the corresponding claim in docs/post-migration-review.md's
Plan section (item 8), which had cited this test by name.

No production code changed in this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zXVpxEbSWadqKyntXTXD6
@Marcogn
Marcogn marked this pull request as ready for review September 5, 2026 07:45
@Marcogn
Marcogn merged commit 0402d29 into main Sep 5, 2026
1 check passed
@Marcogn
Marcogn deleted the claude/android-migration-verification-vt7gru branch September 5, 2026 07:45
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.

2 participants