Website: machine estimator v2 — fold-out categories and the memory bar (still unlisted, R-997)
gates / gates (push) Successful in 6m31s

Each category is a native <details>, closed at first, with an 'n selected' count;
a preset opens what it ticks. A sticky memory bar (0-64 GB, marks 8/16/24/32/48/64+):
solid fill = smallest box memory where the selection fits well, thin mark = where it
fits, but tight; one line names the box to buy. minBoxGb() inverts guestMib() by
bisection on it, so the rule is still written once. Box sizes 12/24/48/64 added;
tests cover seven sizes and the inverse (red-proofed); SIZING.md table gains 24 GB.
This commit is contained in:
2026-10-13 15:38:45 +02:00
parent d5ef93b13b
commit 16dca64834
11 changed files with 277 additions and 110 deletions
+9
View File
@@ -1,3 +1,12 @@
## 2026-10-13 (afternoon) — estimator v2: `build_sizing_web.py` box sizes, tests for the inverse
- `build_sizing_web.py`: `boxes_gb` = 8, 12, 16, 24, 32, 48, 64; new `bar_marks_gb` and `bar_max_gb`.
- `test_build_sizing_web.py` (8 tests): the four cases × seven sizes, with the recommended („fits well”) and the
smallest „tight” size each; the inverse agrees with the forward rule (the guest at the answer holds the need, one
step smaller does not); SIZING.md's table is read by its header and must carry the 24 GB column; `sizing.js` may
hold only 0, 1, 2, 100 and 1024. Red-proofed: a wrong 24 GB cell, a missing 24 GB column, a hard-coded 64 in the
JS, and an inverse 1 GB too big each convicted.
## 2026-10-13 — `build_sizing_web.py` + `site_gates.py` UNLISTED pairs and gate 25
- **`build_sizing_web.py`** builds `website/assets/sizing-data.json` (the machine estimator's data) from
+5 -1
View File
@@ -33,7 +33,9 @@ HEADROOM = 1.5
# 15,772 MiB (measured 2026-10-13), so a box of N GB is taken as N*1024 - 612 MiB.
GUEST_RULE = {"reserve_mib": 4096, "max_gap_mib": 2048, "min_mib": 4096, "ceiling_gap_mib": 1024,
"memtotal_shortfall_mib": 612}
BOXES_GB = [8, 16, 32]
BOXES_GB = [8, 12, 16, 24, 32, 48, 64] # the common sizes a recommendation names (v2: 12, 24, 48, 64 added)
BAR_MARKS_GB = [8, 16, 24, 32, 48, 64] # the memory bar's marks; the last one reads "64+"
BAR_MAX_GB = 64 # the bar's linear scale is 0..BAR_MAX_GB
BUILTIN = {"filebrowser"}
# One short line per medium / heavy-in-bursts app (the 9 of 2026-10-13). sizing.json's English notes, tidied.
@@ -115,6 +117,8 @@ def build():
headroom=HEADROOM,
guest_rule=GUEST_RULE,
boxes_gb=BOXES_GB,
bar_marks_gb=BAR_MARKS_GB,
bar_max_gb=BAR_MAX_GB,
categories=[dict(id=h[0], id_en=e[0], name_hu=h[1], name_en=e[1]) for h, e in zip(hu_cats, en_cats)],
apps=apps,
)
+55 -11
View File
@@ -36,6 +36,19 @@ def guest_mib(d, gb): # mirrors sizing.js guestMib()
return mem
def min_box_gb(d, need): # mirrors sizing.js minBoxGb(): a bisection on guest_mib, never a 2nd rule
lo, hi = 0.0, 1.0
while guest_mib(d, hi) < need:
hi *= 2
while hi - lo > 1 / 1024:
mid = (lo + hi) / 2
if guest_mib(d, mid) >= need:
hi = mid
else:
lo = mid
return hi
def estimate(d, slugs): # mirrors sizing.js estimate()
by = {a["slug"]: a for a in d["apps"]}
peak = d["platform_mb"] + sum(by[s]["peak_mb"] for s in slugs if not by[s].get("builtin"))
@@ -46,6 +59,10 @@ def estimate(d, slugs): # mirrors sizing.js estimate()
return peak, out
def first(d, verdicts, want): # mirrors recommend_gb / tight_box_gb in sizing.js
return next((gb for gb, v in zip(d["boxes_gb"], verdicts) if v == want), None)
class SizingWeb(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -61,19 +78,39 @@ class SizingWeb(unittest.TestCase):
self.assertEqual([a["slug"] for a in self.d["apps"] if a.get("builtin")], ["filebrowser"])
def test_guest_sizes_follow_the_installer(self):
self.assertEqual([guest_mib(self.d, gb) for gb in (8, 16, 32)], [4096, 11676, 28060])
self.assertEqual(self.d["boxes_gb"], [8, 12, 16, 24, 32, 48, 64])
self.assertEqual([guest_mib(self.d, gb) for gb in self.d["boxes_gb"]],
[4096, 7580, 11676, 19868, 28060, 44444, 60828])
self.assertEqual(guest_mib(self.d, 4), 4 * 1024 - 612 - 1024) # a 4 GB host: the ceiling, not 4 GB
def test_the_four_cases(self):
# columns: 8, 12, 16, 24, 32, 48, 64 GB; then the recommended ("fits well") and the smallest tight size
W, T, N = "well", "tight", "no"
cases = {
"household": (HOUSEHOLD, ["no", "well", "well"]),
"household without immich": ([s for s in HOUSEHOLD if s != "immich"], ["well", "well", "well"]),
"immich alone": (["immich"], ["tight", "well", "well"]),
"all 62": (self.all, ["no", "no", "tight"]),
"nothing": ([], ["well", "well", "well"]),
"household": (HOUSEHOLD, [N, T, W, W, W, W, W], 16, 12),
"household without immich": ([s for s in HOUSEHOLD if s != "immich"], [W, W, W, W, W, W, W], 8, None),
"immich alone": (["immich"], [T, W, W, W, W, W, W], 12, 8),
"all 62": (self.all, [N, N, N, N, T, W, W], 48, 32), # 24 GB: guest 19,868 < peak 21,783
"nothing": ([], [W] * 7, 8, None),
}
for name, (sel, want) in cases.items():
self.assertEqual(estimate(self.d, sel)[1], want, name)
for name, (sel, want, rec, tight) in cases.items():
peak, verdicts = estimate(self.d, sel)
self.assertEqual(verdicts, want, name)
self.assertEqual(first(self.d, verdicts, "well"), rec, name + " recommended")
self.assertEqual(first(self.d, verdicts, "tight"), tight, name + " smallest tight")
def test_inverse_agrees_with_the_forward_rule(self):
cases = [HOUSEHOLD, [s for s in HOUSEHOLD if s != "immich"], ["immich"], self.all, []]
for sel in cases:
peak, _ = estimate(self.d, sel)
for need in (peak * self.d["headroom"], peak):
gb = min_box_gb(self.d, need)
self.assertGreaterEqual(guest_mib(self.d, gb), need) # it fits at the answer
self.assertLess(guest_mib(self.d, gb - 2 / 1024), need) # and not one step smaller
# the recommendation is the first listed size at or above the fill
rec = next((b for b in self.d["boxes_gb"] if guest_mib(self.d, b) >= need), None)
if rec is not None:
self.assertGreaterEqual(rec, gb - 1 / 1024)
def test_sizing_md_table_agrees_with_the_rule(self):
with io.open(SIZING_MD, encoding="utf-8") as f:
@@ -83,7 +120,9 @@ class SizingWeb(unittest.TestCase):
rows = {}
for line in block.group(1).splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) == 5 and cells[0] not in ("Selection", "---"):
if cells and cells[0] == "Selection":
cols = [int(c.split()[0]) for c in cells[2:]]
elif len(cells) > 2 and not cells[0].startswith("---"):
rows[cells[0]] = cells
sel = {"Household set": HOUSEHOLD, "Household set without Immich": [s for s in HOUSEHOLD if s != "immich"],
"Immich alone": ["immich"], "All 62 apps": self.all}
@@ -91,7 +130,9 @@ class SizingWeb(unittest.TestCase):
for name, slugs in sel.items():
peak, verdicts = estimate(self.d, slugs)
self.assertEqual(rows[name][1], "%.1f GB" % (peak / 1024), name + " peak")
self.assertEqual(rows[name][2:], [WORDS[v] for v in verdicts], name)
by = dict(zip(self.d["boxes_gb"], verdicts))
self.assertIn(24, cols, "SIZING.md's table lacks the 24 GB column")
self.assertEqual(rows[name][2:], [WORDS[by[c]] for c in cols], name)
def test_js_has_the_rule_and_none_of_its_numbers(self):
js = io.open(JS, encoding="utf-8").read()
@@ -103,7 +144,10 @@ class SizingWeb(unittest.TestCase):
code = re.sub(r"(?m)//.*$", "", code)
code = re.sub(r"'(?:[^'\\]|\\.)*'", "''", code)
nums = set(re.findall(r"(?<![\w.])\d+(?:\.\d+)?", code))
self.assertTrue(nums <= {"0", "1", "2", "1024"}, "sizing.js holds its own numbers: %s" % sorted(nums - {"0", "1", "2", "1024"}))
allowed = {"0", "1", "2", "100", "1024"} # 100 = percent, 1024 = MiB per GiB; no rule constant
self.assertTrue(nums <= allowed, "sizing.js holds its own numbers: %s" % sorted(nums - allowed))
for need in ("data.bar_max_gb", "data.bar_marks_gb", "function minBoxGb", "guestMib(data, mid) >= needMib"):
self.assertIn(need, js)
def test_data_has_no_peak_below_typical(self):
for a in self.d["apps"]: