@@ -1235,6 +1235,8 @@ struct Browser
12351235 at(0 , height - 1 , { drawOverflowMarker(bottomOverflowText); });
12361236 };
12371237
1238+ string undiscoveredEst;
1239+
12381240 void drawItems ()
12391241 {
12401242 auto totalUniqueSamples = getTotalUniqueSamplesFor(currentPath);
@@ -1301,6 +1303,8 @@ struct Browser
13011303 " /" .length +
13021304 6 ;
13031305
1306+ typeof (x) fileNameX; // Captured from loop for undiscovered estimate alignment
1307+
13041308 foreach (i, child; items)
13051309 {
13061310 auto childY = cast (int )(i - itemScrollContext.y.offset);
@@ -1377,6 +1381,7 @@ struct Browser
13771381 }
13781382 else
13791383 {
1384+ fileNameX = x;
13801385 write(child.firstChild is null ? ' ' : ' /' );
13811386
13821387 withWindow(x, y, maxItemWidth.to! xy_t, 1 , {
@@ -1390,6 +1395,20 @@ struct Browser
13901395 });
13911396 });
13921397 }
1398+
1399+ // Render undiscovered estimate below file list
1400+ if (undiscoveredEst ! is null )
1401+ {
1402+ auto undiscoveredY = cast (int )(items.length - itemScrollContext.y.offset);
1403+ if (undiscoveredY >= 0 && undiscoveredY < itemScrollContext.y.contentAreaSize)
1404+ {
1405+ dimmed({
1406+ write(formatted! " %*s" (fileNameX, " " ), " [" , undiscoveredEst, " ]" , endl);
1407+ });
1408+ }
1409+ else
1410+ y++ ; // Advance y even if not visible for content size tracking
1411+ }
13931412 }
13941413
13951414 alias drawInfoPanel = (titlePrefix, overflowKeyText, bool fullScreen, auto ref ScrollContext scrollContext, BrowserPath* p)
@@ -1423,7 +1442,9 @@ struct Browser
14231442 auto infoWidth = infoPanelsVisible ? min(60 , (width - 1 ) / 2 ) : 0 ;
14241443 auto itemsWidth = infoPanelsVisible ? width - infoWidth - 1 : width;
14251444 withWindow(infoPanelsVisible ? infoWidth + 1 : 0 , 0 , itemsWidth, height, {
1426- itemScrollContext.y.contentSize = items.length;
1445+ undiscoveredEst = currentPath is &marked ? null : estimateUndiscoveredStr(currentPath);
1446+ auto hasUndiscoveredRow = undiscoveredEst ! is null ;
1447+ itemScrollContext.y.contentSize = items.length + (hasUndiscoveredRow ? 1 : 0 );
14271448 itemScrollContext.y.contentAreaSize = height - 1 ;
14281449 itemScrollContext.y.cursor = selection && items ? items.countUntil(selection) : 0 ;
14291450 itemScrollContext.y.normalize();
@@ -1438,8 +1459,6 @@ struct Browser
14381459 displayedPath = buf2.stringify! " …%s" (displayedPath[$ - (maxPathWidth - 1 ) .. $]);
14391460 drawPanel(bold(displayedPath), null , null , itemScrollContext, 0 , 1 , &drawItems);
14401461 }
1441-
1442- assert (itemScrollContext.y.contentSize == items.length);
14431462 });
14441463
14451464 if (infoPanelsVisible)
@@ -2340,6 +2359,150 @@ SizeEstimate estimateError(
23402359 );
23412360}
23422361
2362+ // / Include explanations in the displayed undiscovered estimate.
2363+ // debug debug = btdu_undiscovered;
2364+
2365+ // / Estimate undiscovered children using Chao1 estimator with confidence indicators.
2366+ // / Returns a string like "and ~500 more items" or "and likely more items",
2367+ // / or null if complete.
2368+ // /
2369+ // / Decision categories (in order):
2370+ // / 1. complete: n1=0 AND global_C≥30% AND (obs≥10 OR samples≥50×obs) → null (no display)
2371+ // / 2. early: global_coverage < 30% → "and likely more"
2372+ // / 3. near-complete: obs≥20 AND (n1<10 OR n1/obs<5% OR f0<5) → "and about X more"
2373+ // / 4. unknown: n2 < 5 → "and likely more"
2374+ // / 5. biased: iChao1 > 1.5×Chao1 → "and likely more"
2375+ // / 6. lower-bound: H > 0.5 → "and roughly at least X more"
2376+ // / 7. ballpark: 0.25 < H ≤ 0.5 → "and roughly X more"
2377+ // / 8. strong: H ≤ 0.25 → "and ~X more"
2378+ string estimateUndiscoveredStr (BrowserPath* path)
2379+ {
2380+ import std.math : sqrt, ceil;
2381+ import std.algorithm : max;
2382+
2383+ // Helper: in debug builds, append explanation to display string
2384+ static string withExplanation (string display, lazy string explanation)
2385+ {
2386+ debug (btdu_undiscovered)
2387+ return display is null ? null : display ~ " " ~ explanation;
2388+ else
2389+ return display;
2390+ }
2391+
2392+ // Count frequency statistics among children
2393+ size_t observed = 0 ; // Total observed children
2394+ size_t n1 = 0 ; // Singletons: children with exactly 1 sample
2395+ size_t n2 = 0 ; // Doubletons: children with exactly 2 samples
2396+ size_t n3 = 0 ; // Tripletons: children with exactly 3 samples
2397+ size_t n4 = 0 ; // Children with exactly 4 samples
2398+
2399+ for (auto child = path.firstChild; child; child = child.nextSibling)
2400+ {
2401+ observed++ ;
2402+ auto samples = child.getSamples(SampleType.represented);
2403+ if (samples == 1 ) n1++ ;
2404+ else if (samples == 2 ) n2++ ;
2405+ else if (samples == 3 ) n3++ ;
2406+ else if (samples == 4 ) n4++ ;
2407+ }
2408+
2409+ // Global coverage from numSingleSampleGroups (sharing groups sampled exactly once)
2410+ auto totalSamples = getTotalUniqueSamplesFor(&browserRoot);
2411+ double globalC = totalSamples > 0 ? 1.0 - cast (double )numSingleSampleGroups / totalSamples : 0.0 ;
2412+
2413+ // Samples hitting this directory (for saturation check)
2414+ auto pathSamples = path.getSamples(SampleType.represented);
2415+
2416+ // === Decision logic ===
2417+
2418+ // Use softer language when only one item discovered
2419+ string likelyMore = observed == 1 ? " and possibly more items" : " and likely more items" ;
2420+
2421+ // Complete: no singletons, but only if good global coverage and sufficient obs
2422+ // Three ways to claim complete:
2423+ // 1. We have substantial observations (obs >= 10)
2424+ // 2. We're saturated: many samples but few unique items (50x for obs >= 2)
2425+ // 3. Single item with very high saturation (100x for obs == 1)
2426+ // Be more conservative for single item since we can't know if tiny items exist
2427+ if (n1 == 0 )
2428+ {
2429+ if (globalC < 0.3 )
2430+ return withExplanation (likelyMore,
2431+ format! " (n1=0 but C=%.0f%%)" (globalC * 100 ));
2432+ bool hasEnoughObs = observed >= 10 ;
2433+ bool isSaturated = observed >= 2 && pathSamples >= 50 * observed;
2434+ bool isSingleSaturated = observed == 1 && pathSamples >= 100 * observed;
2435+ if (! hasEnoughObs && ! isSaturated && ! isSingleSaturated)
2436+ return withExplanation (likelyMore,
2437+ format! " (n1=0 but obs=%d, samples=%d)" (observed, cast (size_t )pathSamples));
2438+ return null ; // Truly complete
2439+ }
2440+
2441+ // Early: global coverage < 30%
2442+ if (globalC < 0.3 )
2443+ return withExplanation (likelyMore,
2444+ format! " (early: C=%.0f%%, n1=%d, n2=%d)" (globalC * 100 , n1, n2));
2445+
2446+ // Compute Chao1 estimate
2447+ double f0_est;
2448+ if (n2 == 0 )
2449+ f0_est = n1 * (n1 - 1 ) / 2.0 ;
2450+ else
2451+ f0_est = cast (double )(n1 * n1) / (2.0 * n2);
2452+
2453+ // Helper: format "and <qualifier> N more item(s)"
2454+ static string moreItemsStr (string qualifier)(double n)
2455+ {
2456+ auto count = ceil(n);
2457+ return format! " and %s%.0f more %s" (qualifier, count, count == 1 ? " item" : " items" );
2458+ }
2459+
2460+ // Near-complete: few singletons relative to observed (but need enough obs to trust it)
2461+ if (observed >= 20 )
2462+ {
2463+ double singletonRatio = cast (double )n1 / observed;
2464+ if (n1 < 10 || singletonRatio < 0.05 || f0_est < 5 )
2465+ return withExplanation (moreItemsStr! " about " (f0_est),
2466+ format! " (near-complete: obs=%d, n1=%d, n2=%d, f0=%.1f)" (observed, n1, n2, f0_est));
2467+ }
2468+
2469+ // Unknown: can't compute reliable SE
2470+ if (n2 < 5 )
2471+ return withExplanation (likelyMore,
2472+ format! " (unknown: n2=%d<5, obs=%d, n1=%d)" (n2, observed, n1));
2473+
2474+ // Bias check using iChao1
2475+ double f0_ichao1 = f0_est;
2476+ if (n4 > 0 && n3 > 0 )
2477+ {
2478+ double correction = (cast (double )n3 / (4 * n4)) * max(0.0 , n1 - cast (double )n2 * n3 / (2 * n4));
2479+ f0_ichao1 = f0_est + correction;
2480+ }
2481+ else if (n3 > 0 && n2 > 0 )
2482+ {
2483+ f0_ichao1 = f0_est * (1 + cast (double )n3 / (2 * n2));
2484+ }
2485+ if (f0_ichao1 > 1.5 * f0_est)
2486+ return withExplanation (likelyMore,
2487+ format! " (biased: iChao1=%.0f >> Chao1=%.0f)" (f0_ichao1, f0_est));
2488+
2489+ // Compute SE and reliability score H
2490+ double a = cast (double )n1 / n2;
2491+ double var_s = n2 * (a * a * a * a / 4.0 + a * a * a + a * a / 2.0 );
2492+ double se = sqrt(var_s);
2493+ double H = 1.96 * se / max(f0_est, 1.0 );
2494+
2495+ if (H > 0.5 )
2496+ return withExplanation (moreItemsStr! " roughly at least " (f0_est),
2497+ format! " (lower-bound: H=%.2f, n1=%d, n2=%d)" (H, n1, n2));
2498+ else if (H > 0.25 )
2499+ return withExplanation (moreItemsStr! " roughly " (f0_est),
2500+ format! " (ballpark: H=%.2f, n1=%d, n2=%d)" (H, n1, n2));
2501+ else
2502+ return withExplanation (moreItemsStr! " ~" (f0_est),
2503+ format! " (strong: H=%.2f, n1=%d, n2=%d)" (H, n1, n2));
2504+ }
2505+
23432506auto durationAsDecimalString (Duration d) @nogc
23442507{
23452508 assert (d >= Duration.zero);
0 commit comments