Skip to content

Commit c0842cb

Browse files
committed
btdu.ui.browser: Show estimate of undiscovered items in directories
Display an estimate of how many more items might exist in a directory that haven't been sampled yet, appearing below the file list. Uses the Chao1 estimator based on singleton and doubleton counts among observed children. The estimate uses varying confidence levels: - "and ~X more items" for high confidence estimates - "and roughly X more" or "and roughly at least X more" for moderate confidence - "and likely more items" when data is insufficient for a numeric estimate - Nothing shown when sampling indicates the directory is complete Decision logic considers global sample coverage, singleton/doubleton ratios, iChao1 bias correction, and standard error reliability. A debug mode (btdu_undiscovered) is available to display the statistical reasoning behind each estimate.
1 parent 18e55f3 commit c0842cb

1 file changed

Lines changed: 166 additions & 3 deletions

File tree

source/btdu/ui/browser.d

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
23432506
auto durationAsDecimalString(Duration d) @nogc
23442507
{
23452508
assert(d >= Duration.zero);

0 commit comments

Comments
 (0)