grid scan: a raster reaches the spot engine, and the crystal cap says none rather than zero

Two defects the merge created and one the API carried.

Rugnux gated the per-image spot engine on AnalysisModeIsMX, so AnalysisMode::Grid
fell through to the azimuthal-integration-only path: a raster ran, scored nothing,
and reported no crystals. The gate now asks the stages table whether the mode does
spot finding, which is the actual question - three modes need that engine for three
different reasons, and a fourth would otherwise have to be remembered here too.

max_crystals was a required integer defaulting to 10, with 0 meaning "all". Zero
reads as "report no crystals", the opposite of what it did. It is now optional, and
absent means no cap; a crystal found and then dropped is information the caller
cannot get back. grow_score_threshold was missing from the schema entirely.

Measured over the labelled corpus after these fixes: 34 of 34 confirmed-protein
rasters yield a crystal, 0 of 8 water, 0 of 6 ice, 19 of 19 heldout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-08 08:22:14 +02:00
co-authored by Claude Opus 5
parent 37efa573c4
commit d4f280047f
13 changed files with 166 additions and 62 deletions
+6 -5
View File
@@ -72,7 +72,8 @@ TEST_CASE("RasterReport_Render", "[Diagnostics]") {
CHECK(report.find("GROW_SCORE_THRESHOLD= 0.35\n") != std::string::npos);
CHECK(report.find("MIN_BLOB_CELLS= 3\n") != std::string::npos);
CHECK(report.find("DECISIVE_PROTEIN_SCORE= 0.60\n") != std::string::npos);
CHECK(report.find("MAX_CRYSTALS= 0\n") != std::string::npos);
// Unset means no cap; "0" would read as "report no crystals".
CHECK(report.find("MAX_CRYSTALS= none\n") != std::string::npos);
CHECK(report.find("CRYSTAL_COUNT= 1\n") != std::string::npos);
CHECK(report.find("END OF REPORT") != std::string::npos);
}
@@ -196,7 +197,7 @@ TEST_CASE("RasterReport_ThreeCrystalsAndTheCap", "[Diagnostics]") {
CHECK(all.crystals[2].score == Catch::Approx(0.75).margin(1e-5));
// The cap keeps the strongest, not the first found: the 0.85 patch is the last one in grid order.
settings.analysis.max_crystals = 2;
settings.analysis.MaxCrystals(2);
const GridScanResult capped = AnalyzeGridScan(scan, grid, settings.beam_size_x_um,
settings.beam_size_y_um, settings.analysis);
REQUIRE(capped.crystals.size() == 2);
@@ -225,7 +226,7 @@ TEST_CASE("RasterReport_OneDecisiveCellIsACrystal", "[Diagnostics]") {
// Raise the bar past the one cell that passed and nothing is left; a patch that meets the cell
// minimum is unaffected by the bar, which is what makes this an OR and not a second gate.
settings.analysis.decisive_protein_score = 0.99f;
settings.analysis.DecisiveSingleCellScore(0.99f);
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
@@ -250,7 +251,7 @@ TEST_CASE("RasterReport_HysteresisHealsASplit", "[Diagnostics]") {
CHECK(healed.crystals[0].n_images == 7); // the bridging cell is part of the crystal
// Growing no further than the seed level is the behaviour hysteresis replaced, and it splits.
settings.analysis.grow_score_threshold = settings.analysis.protein_score_threshold;
settings.analysis.GrowScoreThreshold(settings.analysis.GetProteinScoreThreshold());
const GridScanResult split = AnalyzeGridScan(scan, grid, settings.beam_size_x_um,
settings.beam_size_y_um, settings.analysis);
CHECK(split.crystals.size() == 2);
@@ -266,7 +267,7 @@ TEST_CASE("RasterReport_GrowthCannotStartOnItsOwn", "[Diagnostics]") {
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
settings.analysis.grow_score_threshold = 0.05f;
settings.analysis.GrowScoreThreshold(0.05f);
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
}