hub v0.119.0 — English households get English words for their codes (R-597); R-596/R-598 closed
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
The setup code and the owner passphrase now follow the household's language,
one word longer in English so the entropy never drops (setup 3 hu / 4 en,
passphrase 5 hu / 6 en). List and count are chosen together so a caller cannot
pair an English list with a Hungarian count. Hungarian is byte-unchanged.
Three claims in the row were wrong and are recorded as such:
- the RECOVERY CODE is minted by felhom-agent from the EFF list and has
always been English; the hub does not own it and no row was added.
- no claim mail states a word count; the only count wording was the bind
page's passphrase hint, whose English half is now count-free.
- the proposed phone-safe filter removes 68% of the list (5270 of 7772
words) and was measured, then declined, with the reason in source.
Also: guide_quote_gate binds the English volunteer guide's three quoted
messages to the controller's English bundle — nothing did, so the guide would
have gone on quoting Hungarian after the fix. Seven decoys, all convicting,
including the name-for-fact one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""guide_quote_gate -- the English guide may only quote what the screen actually says (R-596/R-598).
|
||||
|
||||
Run from anywhere: python3 scripts/guide_quote_gate.py
|
||||
Exit 0 clean * 1 convicted * 2 inconclusive (a file it needs is missing).
|
||||
|
||||
WHY THIS EXISTS. `documentation/runbooks/VOLUNTEER-first-hour.en.md` is what a volunteer tester
|
||||
follows instead of the Hungarian guide. Three of its lines QUOTE messages the dashboard prints:
|
||||
the claim page's wrong-code and lockout answers, and the Backup page's system-disk warning. Those
|
||||
three sentences live in the CONTROLLER's English bundle, in a different repo, and nothing bound
|
||||
them together -- so the guide would have gone on quoting Hungarian for as long as nobody re-walked
|
||||
it. That is exactly how it read for a day: the 2026-09-20 drill's guide quoted the Hungarian
|
||||
because the Hungarian was what shipped, and when v0.259.0 changed the screen the guide became wrong
|
||||
in the other direction.
|
||||
|
||||
WHAT IT CHECKS. Each entry below names a bundle KEY and asserts its English value appears verbatim
|
||||
in the guide. A reworded key, or a reworded guide, convicts and prints both sides.
|
||||
|
||||
WHAT IT DOES NOT CHECK, deliberately: that the guide is complete, or that any OTHER sentence in it
|
||||
is accurate. It binds the three quotes that are quotes. Everything else in that document is prose a
|
||||
person has to re-walk, and pretending otherwise would be the label without the fact.
|
||||
|
||||
SCOPE IS A FACT. The controller clone may not sit beside this one (CI checks out one repo). An
|
||||
absent sibling is INCONCLUSIVE (exit 2), never a silent pass -- the failure this project keeps
|
||||
closing is a check that reports green because it could not look.
|
||||
|
||||
THE DECOY (R-421). scripts/test_guide_quote_gate.py builds a guide that MENTIONS every key by name
|
||||
in prose -- the label without the fact -- and asserts this gate still convicts, because it compares
|
||||
the VALUE, not the key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
WORKSPACE = os.path.dirname(REPO)
|
||||
|
||||
GUIDE = os.path.join(REPO, "documentation", "runbooks", "VOLUNTEER-first-hour.en.md")
|
||||
EN_BUNDLE = os.path.join(
|
||||
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
|
||||
)
|
||||
|
||||
# key -> why the guide quotes it. The reason is part of the record: a future session deciding to
|
||||
# drop a quote should have to argue with the reason, not just with the list.
|
||||
QUOTED = {
|
||||
"claim.msg.bad_code":
|
||||
"section 13 -- the answer to a mistyped setup code. This is the sentence the 2026-09-20 "
|
||||
"English drill stopped on; a tester who reads a different one cannot tell a typo from a "
|
||||
"dead code, which is the whole point of the section.",
|
||||
"claim.msg.too_many":
|
||||
"section 13 -- the lockout answer after five wrong codes. The guide promises 15 minutes; "
|
||||
"if the message ever says something else, the guide is telling a tester to wait wrongly.",
|
||||
"claim.msg.password_too_short":
|
||||
"section 13 -- the minimum-password answer. Quoted with its number filled in, so the guide "
|
||||
"and claimMinPassword cannot disagree silently.",
|
||||
"backup.target.degraded":
|
||||
"section 9 -- the warning that says the backup does NOT survive a disk failure. It is a "
|
||||
"promise about whether the tester's files are safe.",
|
||||
}
|
||||
|
||||
# Keys whose English carries a printf verb: the guide quotes the rendered form. value -> rendered.
|
||||
RENDER = {
|
||||
"claim.msg.password_too_short": lambda v: v % 12,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.path.exists(GUIDE):
|
||||
print("guide-quote: INCONCLUSIVE -- no %s" % os.path.relpath(GUIDE, REPO), file=sys.stderr)
|
||||
return 2
|
||||
if not os.path.exists(EN_BUNDLE):
|
||||
print(
|
||||
"guide-quote: INCONCLUSIVE -- the felhom-controller clone is not beside this one "
|
||||
"(looked for %s). Not a pass: this gate cannot see what the screen says." % EN_BUNDLE,
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
guide = io.open(GUIDE, encoding="utf-8").read()
|
||||
bundle = json.load(io.open(EN_BUNDLE, encoding="utf-8"))
|
||||
|
||||
problems = []
|
||||
for key, why in sorted(QUOTED.items()):
|
||||
if key not in bundle:
|
||||
problems.append("MISSING KEY %s\n en.json does not know it.\n %s" % (key, why))
|
||||
continue
|
||||
want = bundle[key]
|
||||
if key in RENDER:
|
||||
want = RENDER[key](want)
|
||||
if want not in guide:
|
||||
problems.append(
|
||||
"NOT QUOTED %s\n"
|
||||
" the screen says: %r\n"
|
||||
" the guide does not contain that sentence.\n"
|
||||
" %s" % (key, want, why)
|
||||
)
|
||||
|
||||
print("guide-quote: %d quoted messages checked against the controller's English bundle" % len(QUOTED))
|
||||
if problems:
|
||||
print("\nguide-quote gate CONVICTS (%d):" % len(problems))
|
||||
for p in problems:
|
||||
print(" " + p)
|
||||
print(
|
||||
"\nThe English guide and the English dashboard disagree. Fix whichever is wrong --\n"
|
||||
"but a tester follows the guide, so a guide that quotes a sentence nobody sees is the\n"
|
||||
"more expensive of the two."
|
||||
)
|
||||
return 1
|
||||
print("guide-quote gate OK: every quoted message is what the screen says.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,6 +17,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
7. golden-currency a released controller has a golden carrying it (R-242)
|
||||
8. wire-contract every emitted field is decodable by its receiver (G-1)
|
||||
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
|
||||
9b. guide-quote the English volunteer guide, against the controller's English bundle (R-596)
|
||||
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
|
||||
11. one-register open work living outside OPEN-ITEMS.md (R-369)
|
||||
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
|
||||
@@ -125,6 +126,11 @@ GATES = [
|
||||
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
|
||||
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
|
||||
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
|
||||
# R-596/R-598 — the English guide QUOTES three dashboard messages that live in the controller's
|
||||
# bundle, in another repo. Nothing bound them, so the guide quoted Hungarian for as long as the
|
||||
# Hungarian shipped, and would have quoted it after the fix too. INCONCLUSIVE (exit 2, reported
|
||||
# not swallowed) when the controller clone is absent. Fast: two file reads.
|
||||
("guide-quote", os.path.join(SCRIPTS, "guide_quote_gate.py"), [], True, False),
|
||||
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
|
||||
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
|
||||
# R-369 — two files held open work and only one called itself the source of truth, so a READY
|
||||
|
||||
@@ -45,6 +45,12 @@ COVERS = {
|
||||
"golden-currency": "R-410: an empty directory with a perfect name, checked by what it COUNTED",
|
||||
"closed-register": "a verdict cell reading open, and a row with no state cell at all",
|
||||
"decoy-coverage": "a gate registered in a runner with no decoy and no exemption (its red-proof)",
|
||||
"guide-quote": ("R-596: SEVEN cases in scripts/test_guide_quote_gate.py, run from here so "
|
||||
"this suite stays the single entry point. The load-bearing one is "
|
||||
"name-for-fact: a guide that lists every key in a table and quotes none of "
|
||||
"their sentences. Also: a prefix of the real sentence, the OLD Hungarian "
|
||||
"quote, a reworded bundle, a deleted key, and the scope case — an absent "
|
||||
"controller clone must be INCONCLUSIVE, never a pass"),
|
||||
"hub-copy": ("R-558: an English retrieval promise in the NEW bundle (the sentences moved "
|
||||
"out of templates.go, so the surface list had to move with them), the same "
|
||||
"in Hungarian, and an INNOCENT control using the identical verbs without the "
|
||||
@@ -286,6 +292,25 @@ elif "decoy-red-proof" not in _out:
|
||||
else:
|
||||
print(" ok %-20s a new gate with no decoy is convicted BY NAME" % "decoy-coverage")
|
||||
|
||||
# ── guide-quote (R-596) ──────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Its decoys build whole fake workspaces (a guide plus a sibling controller clone), which does not
|
||||
# fit the plant/restore shape above — so they live in their own file and are RUN from here. The
|
||||
# decoy-coverage gate reads COVERS in this file, so this is the seam that keeps that entry honest:
|
||||
# if the separate suite stops passing, this one fails, and the COVERS line stops being a label.
|
||||
ran += 1
|
||||
_gq = subprocess.run([sys.executable, os.path.join("scripts", "test_guide_quote_gate.py")],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
if _gq.returncode == 2:
|
||||
fails.append("guide-quote: its decoy suite could not run (no controller clone beside this one) — "
|
||||
"INCONCLUSIVE is not coverage\n%s" % (_gq.stdout + _gq.stderr)[-500:])
|
||||
elif _gq.returncode != 0:
|
||||
fails.append("guide-quote: its decoy suite FAILED — a decoy did not convict\n%s"
|
||||
% (_gq.stdout + _gq.stderr)[-800:])
|
||||
else:
|
||||
_n = _gq.stdout.strip().splitlines()[-1] if _gq.stdout.strip() else "?"
|
||||
print(" ok %-20s %s" % ("guide-quote", _n))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
for f in fails:
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decoys for guide_quote_gate (R-421). Run: python3 scripts/test_guide_quote_gate.py
|
||||
|
||||
A decoy is the LABEL without the FACT. The shape this gate is most at risk of is
|
||||
**name-for-fact**: matching the KEY NAME where the fact is the key's VALUE. A guide that lists
|
||||
`claim.msg.bad_code` in a table of "messages covered" would satisfy a name-matching gate and tell a
|
||||
tester nothing, because a tester reads sentences, not keys.
|
||||
|
||||
The second shape checked here is **declaration-for-reachability** in its sibling form: a gate that
|
||||
reports OK when it could not read the bundle at all. Scope is a fact -- an absent controller clone
|
||||
must be INCONCLUSIVE (2), never a pass.
|
||||
|
||||
Each case runs the real gate against a built tree and asserts the exit code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
WORKSPACE = os.path.dirname(REPO)
|
||||
REAL_BUNDLE = os.path.join(
|
||||
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
|
||||
)
|
||||
|
||||
GATE_SRC = os.path.join(HERE, "guide_quote_gate.py")
|
||||
|
||||
|
||||
def build(tmp, guide_text, bundle=None, with_bundle=True):
|
||||
"""Lay out a fake workspace: <tmp>/felhom.eu/{scripts,documentation/...} + the sibling clone."""
|
||||
repo = os.path.join(tmp, "felhom.eu")
|
||||
scripts = os.path.join(repo, "scripts")
|
||||
runbooks = os.path.join(repo, "documentation", "runbooks")
|
||||
os.makedirs(scripts)
|
||||
os.makedirs(runbooks)
|
||||
shutil.copy(GATE_SRC, os.path.join(scripts, "guide_quote_gate.py"))
|
||||
io.open(os.path.join(runbooks, "VOLUNTEER-first-hour.en.md"), "w", encoding="utf-8").write(guide_text)
|
||||
if with_bundle:
|
||||
locales = os.path.join(tmp, "felhom-controller", "controller", "internal", "i18n", "locales")
|
||||
os.makedirs(locales)
|
||||
if bundle is None:
|
||||
bundle = json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
|
||||
io.open(os.path.join(locales, "en.json"), "w", encoding="utf-8").write(
|
||||
json.dumps(bundle, ensure_ascii=False, indent=2)
|
||||
)
|
||||
return os.path.join(scripts, "guide_quote_gate.py")
|
||||
|
||||
|
||||
def run(gate):
|
||||
p = subprocess.run([sys.executable, gate], capture_output=True, text=True)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
|
||||
def real_bundle():
|
||||
return json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
|
||||
|
||||
|
||||
def honest_guide(b):
|
||||
return (
|
||||
"# guide\n\n"
|
||||
"A wrong code answers **\"%s\"**, and after five tries **\"%s\"**.\n"
|
||||
"A short password answers **\"%s\"**.\n\n> %s\n"
|
||||
% (
|
||||
b["claim.msg.bad_code"],
|
||||
b["claim.msg.too_many"],
|
||||
b["claim.msg.password_too_short"] % 12,
|
||||
b["backup.target.degraded"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
def case(name):
|
||||
def deco(fn):
|
||||
CASES.append((name, fn))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
@case("control: an honest guide passes")
|
||||
def _control(tmp):
|
||||
gate = build(tmp, honest_guide(real_bundle()))
|
||||
rc, out = run(gate)
|
||||
return rc == 0, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("DECOY name-for-fact: the guide NAMES every key and quotes none")
|
||||
def _names(tmp):
|
||||
text = (
|
||||
"# guide\n\nMessages covered by this guide:\n\n"
|
||||
"| key | section |\n|---|---|\n"
|
||||
"| claim.msg.bad_code | 13 |\n"
|
||||
"| claim.msg.too_many | 13 |\n"
|
||||
"| claim.msg.password_too_short | 13 |\n"
|
||||
"| backup.target.degraded | 9 |\n\n"
|
||||
"All four messages are shown in English.\n"
|
||||
)
|
||||
gate = build(tmp, text)
|
||||
rc, out = run(gate)
|
||||
return rc == 1 and "NOT QUOTED" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("DECOY substring: the guide quotes a PREFIX of the real sentence")
|
||||
def _prefix(tmp):
|
||||
b = real_bundle()
|
||||
text = honest_guide(b).replace(b["backup.target.degraded"], b["backup.target.degraded"][:40])
|
||||
gate = build(tmp, text)
|
||||
rc, out = run(gate)
|
||||
return rc == 1 and "backup.target.degraded" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("DECOY the OLD Hungarian quote: the drill-era guide must convict")
|
||||
def _hungarian(tmp):
|
||||
b = real_bundle()
|
||||
text = honest_guide(b).replace(b["claim.msg.bad_code"], "Hibás vagy lejárt kód")
|
||||
gate = build(tmp, text)
|
||||
rc, out = run(gate)
|
||||
return rc == 1 and "claim.msg.bad_code" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("DECOY reworded screen: the bundle changes and the guide does not")
|
||||
def _reworded(tmp):
|
||||
b = real_bundle()
|
||||
text = honest_guide(b)
|
||||
b2 = dict(b)
|
||||
b2["claim.msg.too_many"] = "Too many attempts - try again later."
|
||||
gate = build(tmp, text, bundle=b2)
|
||||
rc, out = run(gate)
|
||||
return rc == 1 and "claim.msg.too_many" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("SCOPE: no controller clone is INCONCLUSIVE (2), never a pass")
|
||||
def _noclone(tmp):
|
||||
gate = build(tmp, honest_guide(real_bundle()), with_bundle=False)
|
||||
rc, out = run(gate)
|
||||
return rc == 2 and "INCONCLUSIVE" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
@case("SCOPE: a key deleted from the bundle convicts, it does not vanish")
|
||||
def _deleted(tmp):
|
||||
b = real_bundle()
|
||||
text = honest_guide(b)
|
||||
b2 = dict(b)
|
||||
del b2["claim.msg.bad_code"]
|
||||
gate = build(tmp, text, bundle=b2)
|
||||
rc, out = run(gate)
|
||||
return rc == 1 and "MISSING KEY" in out, "rc=%d\n%s" % (rc, out)
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(REAL_BUNDLE):
|
||||
print("SKIP: the felhom-controller clone is not beside this one", file=sys.stderr)
|
||||
return 2
|
||||
failed = 0
|
||||
for name, fn in CASES:
|
||||
tmp = tempfile.mkdtemp(prefix="gqg-")
|
||||
try:
|
||||
ok, detail = fn(tmp)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
print((" PASS " if ok else " FAIL ") + name)
|
||||
if not ok:
|
||||
failed += 1
|
||||
print(" " + detail.replace("\n", "\n "))
|
||||
print("\nguide-quote decoys: %d/%d" % (len(CASES) - failed, len(CASES)))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user