Seed repository: rev-8.0.0 reference, frozen oracle, toolchain, test harness
Reference implementation of the strap-beam generators at revision 8.0.0, kept so the acceptance oracle can be regenerated. Not a live target; the running application has no OpenSCAD dependency. The oracle holds 123 frozen cases, 113 accepted and 10 rejected, produced by OpenSCAD 2021.01 with BOSL2 at 92d697c2. The ten rejections are part of the contract: a port that accepts them is wrong. tests/test_oracle.py specifies the port API and was written before the port, so the interface follows from what must be verified rather than what is convenient to implement. Proven by adversarial stub: a build() that rejects everything passes all 10 rejection tests and fails all 226 acceptance tests.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Acceptance oracle — strap-beam revision 8.0.0
|
||||
|
||||
123 cases. 113 accepted, 10 rejected. `sha256 ddd0f154…`
|
||||
|
||||
## What it is for
|
||||
|
||||
This is the acceptance criterion for any reimplementation of the generators. A
|
||||
port is correct when it reproduces these values — not when it looks right, and
|
||||
not when it renders something plausible.
|
||||
|
||||
## The rejected cases are part of the contract
|
||||
|
||||
Ten cases were rejected by the reference, each with a message naming the
|
||||
parameter and the limit. **A port that accepts them is wrong**, however good its
|
||||
numbers are elsewhere.
|
||||
|
||||
This is the part a naive reimplementation loses. Reproducing geometry is
|
||||
straightforward; keeping the constraint that made the geometry trustworthy is
|
||||
the actual work. `tests/test_oracle.py` asserts both directions.
|
||||
|
||||
## What the fields mean
|
||||
|
||||
| Key | Role |
|
||||
|---|---|
|
||||
| `MIN_WALL_ACTUAL_MM` | thinnest surviving material anywhere in the section |
|
||||
| `SECTION_PARTS` | connected solids; must be 1 |
|
||||
| `STRAP_CHANNELS` | separate strap slots; must equal the member count |
|
||||
| `RING_*`, `FIN_*`, `AF_*`, `SPOKE_*` | solved placement values — these pin the solvers |
|
||||
|
||||
The first three are invariants. The solved values are what catch a port that
|
||||
reaches the right shape by a different route, which will diverge later.
|
||||
|
||||
`tolerance` in the document governs comparison: lengths to 1e-4 mm, areas to
|
||||
1e-3 mm², counts exact.
|
||||
|
||||
## The hash
|
||||
|
||||
`fixtures_sha256` covers the document **without that field**, serialised with
|
||||
`indent=2` and `sort_keys=True`. Hashing the file will not reproduce it.
|
||||
|
||||
```bash
|
||||
python3 make_fixtures.py --verify
|
||||
```
|
||||
|
||||
That needs nothing but Python and should pass anywhere, any time.
|
||||
|
||||
## Regenerating
|
||||
|
||||
Only inside the pinned toolchain, never on a host whose OpenSCAD merely looks
|
||||
similar:
|
||||
|
||||
```bash
|
||||
./tools/reference-toolchain/verify.sh --full
|
||||
```
|
||||
|
||||
The `frozen` date changes on every regeneration, so a one-line diff there is
|
||||
expected. **Any other difference means the toolchain has drifted** — stop and
|
||||
investigate. A drifting oracle is worse than no oracle.
|
||||
|
||||
## Coverage
|
||||
|
||||
Beyond the eleven default profiles: bundle counts 2 and 3; conditioned
|
||||
intermediate stock at 13.4 mm and steel strap at 0.79 mm on every profile;
|
||||
asymmetric walls as a chirality regression; parameter sweeps across corner
|
||||
radius, corner web, leg angle, fin projection, fillet radius, bore size,
|
||||
rotation, junction webs, triangle shape and rectangle aspect; and four
|
||||
cross-profile isolation cases proving one profile's parameters cannot affect
|
||||
another's.
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regenerate the frozen rev-8.0.0 acceptance oracle.
|
||||
|
||||
WHAT THIS IS
|
||||
------------
|
||||
The oracle is the acceptance criterion for any reimplementation of the rev-8
|
||||
generators. A port is correct when it reproduces these values -- not when it
|
||||
looks right. The ten rejected cases are part of the contract: a port that
|
||||
accepts them is wrong.
|
||||
|
||||
WHY IT IS PINNED
|
||||
----------------
|
||||
Every case here was produced by OpenSCAD 2021.01 with BOSL2 at commit
|
||||
92d697c2856de2fed93a33e858068589cefc2898. Those versions live in a container
|
||||
image (tools/reference-toolchain) and nowhere else. The running application has
|
||||
no OpenSCAD dependency at all, so the host's OpenSCAD version cannot drift into
|
||||
this file.
|
||||
|
||||
HOW THE HASH WORKS
|
||||
------------------
|
||||
`fixtures_sha256` covers the document WITHOUT that field, serialised with
|
||||
indent=2 and sort_keys=True. Hashing the file itself will not reproduce it.
|
||||
`--verify` performs the correct comparison.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python3 make_fixtures.py --verify # check the committed oracle
|
||||
python3 make_fixtures.py --regenerate # rebuild it (needs OpenSCAD + BOSL2)
|
||||
|
||||
Run --regenerate inside the reference toolchain image, never on a host whose
|
||||
OpenSCAD version is merely similar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REVISION = "8.0.0"
|
||||
OPENSCAD_VERSION = "2021.01"
|
||||
BOSL2_COMMIT = "92d697c2856de2fed93a33e858068589cefc2898"
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO = HERE.parents[1]
|
||||
SCAD_DIR = REPO / "legacy" / "openscad"
|
||||
OUT = HERE / f"strap-beam-fixtures-{REVISION}.json"
|
||||
|
||||
GEN_3X = "strap-beam-3x.scad"
|
||||
GEN_4X = "strap-beam-4x.scad"
|
||||
|
||||
P3 = ["Equilateral Triangle", "General Triangle", "A Frame", "Y", "T", "Three-Fin"]
|
||||
P4 = ["Square", "Rectangle", "Diamond", "Cross", "Four-Fin"]
|
||||
|
||||
# Report keys carrying numeric values. Everything else stays a string.
|
||||
NUM = set(
|
||||
"""STRAP_WIDTH_MM STRAP_THICK_MM BUNDLE_COUNT BUNDLE_THICK_MM CLEARANCE_MM
|
||||
WALL_INSIDE_MM WALL_OUTSIDE_MM WALL_EDGE_MM MIN_WALL_SPEC_MM MIN_WALL_ACTUAL_MM
|
||||
SECTION_AREA_MM2 SECTION_PARTS STRAP_CHANNELS ENVELOPE_X_MM ENVELOPE_Y_MM
|
||||
LENGTH_MM VOLUME_MM3 MASS_G LENGTH_FT RING_SCALE RING_CORNER_WEB_MM
|
||||
RING_CORNER_R_MAX_MM SPOKE_RADIUS_MM SPOKE_WEB_MM FIN_CORE_SIDE_MM
|
||||
FIN_PROJECTION_MM FIN_SETBACK_MM FIN_BORE_SIDE_MM FIN_JUNCTION_WEB_MM
|
||||
AF_APEX_SETBACK_MM AF_CROSSBAR_DEPTH_MM AF_APEX_WEB_MM AF_CROSSBAR_WEB_MM
|
||||
T_FLANGE_SPAN_MM""".split()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Case matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_cases() -> list[dict]:
|
||||
cases: list[dict] = []
|
||||
|
||||
def case(gen: str, profile: str, label: str, defs: list[str]) -> None:
|
||||
cases.append({"generator": gen, "profile": profile, "label": label, "defs": defs})
|
||||
|
||||
for p in P3:
|
||||
case(GEN_3X, p, "default", [])
|
||||
for p in P4:
|
||||
case(GEN_4X, p, "default", [])
|
||||
|
||||
# A bundle is a stack of laminae, not a thicker strap.
|
||||
for p in P3:
|
||||
for b in (2, 3):
|
||||
case(GEN_3X, p, f"bundle{b}", [f"-Dbundle_count={b}"])
|
||||
for p in P4:
|
||||
for b in (2, 3):
|
||||
case(GEN_4X, p, f"bundle{b}", [f"-Dbundle_count={b}"])
|
||||
|
||||
# Conditioned intermediate stock (Utility Two) is a first-class input, not
|
||||
# an edge case. Steel strap thickness likewise.
|
||||
for p in P3 + P4:
|
||||
g = GEN_3X if p in P3 else GEN_4X
|
||||
case(g, p, "width13.4", ["-Dstrap_width_mm=13.4"])
|
||||
case(g, p, "steel0.79", ["-Dstrap_thickness_mm=0.79"])
|
||||
|
||||
# The chirality regression.
|
||||
for p in P3 + P4:
|
||||
g = GEN_3X if p in P3 else GEN_4X
|
||||
case(g, p, "wall_asym", [
|
||||
"-Dinside_wall_thickness_mm=0.8",
|
||||
"-Doutside_wall_thickness_mm=2.4",
|
||||
"-Dmin_wall_mm=0.8",
|
||||
])
|
||||
|
||||
# Profile ranges, including boundaries that MUST be rejected.
|
||||
for a in (20, 30, 45, 53, 55, 70):
|
||||
case(GEN_3X, "A Frame", f"leg{a}", [f"-Da_frame_leg_angle_deg={a}"])
|
||||
for r in (0, 1, 2, 2.8, 2.9, 5):
|
||||
case(GEN_3X, "Equilateral Triangle", f"cornerR{r}", [f"-Dring_corner_radius_mm={r}"])
|
||||
for w in (0.4, 1.2, 2.0, 4.0):
|
||||
case(GEN_3X, "Equilateral Triangle", f"web{w}",
|
||||
[f"-Dring_corner_web_mm={w}", f"-Dmin_wall_mm={min(w, 1.2)}"])
|
||||
for f in (0, 2, 6.25, 12, 25):
|
||||
case(GEN_3X, "Three-Fin", f"fin{f}", [f"-Dthree_fin_fin_mm={f}"])
|
||||
for r in (0, 1, 2, 8):
|
||||
case(GEN_3X, "Three-Fin", f"fillet{r}", [f"-Dthree_fin_junction_round_mm={r}"])
|
||||
for b in (1, 7.5, 15, 30):
|
||||
case(GEN_3X, "Three-Fin", f"bore{b}", [f"-Dthree_fin_bore_side_mm={b}"])
|
||||
for a in (0, 17, 33, 90):
|
||||
case(GEN_3X, "Three-Fin", f"rot{a}", [f"-Dthree_fin_rotation_deg={a}"])
|
||||
for w in (0, 0.5, 1.2, 4.0):
|
||||
case(GEN_3X, "Y", f"ywebb{w}",
|
||||
[f"-Dy_junction_web_mm={w}", f"-Dmin_wall_mm={max(0.01, min(w, 1.2))}"])
|
||||
for w in (0, 0.25, 1.2, 3.0):
|
||||
case(GEN_3X, "T", f"tweb{w}",
|
||||
[f"-Dt_stem_web_mm={w}", f"-Dmin_wall_mm={max(0.01, min(w, 1.2))}"])
|
||||
for h in (4, 8, 15.5, 30):
|
||||
case(GEN_3X, "General Triangle", f"h{h}", [f"-Dgeneral_triangle_height={h}"])
|
||||
for a in (1.0, 1.6, 2.5, 4.0):
|
||||
case(GEN_4X, "Rectangle", f"aspect{a}", [f"-Drectangle_aspect={a}"])
|
||||
for f in (0, 2, 6.25, 15):
|
||||
case(GEN_4X, "Four-Fin", f"fin{f}", [f"-Dfour_fin_fin_mm={f}"])
|
||||
|
||||
# Isolation: a foreign parameter must never affect this profile.
|
||||
case(GEN_3X, "Y", "isolate_aframe", ["-Da_frame_leg_angle_deg=89"])
|
||||
case(GEN_3X, "Y", "isolate_threefin", ["-Dthree_fin_fin_mm=0"])
|
||||
case(GEN_3X, "T", "isolate_gentri", ["-Dgeneral_triangle_height=0.001"])
|
||||
case(GEN_3X, "Equilateral Triangle", "isolate_t", ["-Dt_stem_web_mm=0"])
|
||||
|
||||
return cases
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_case(generator: str, defs: list[str]) -> tuple[dict, str | None]:
|
||||
"""Run one generator invocation; return (report, rejection_message)."""
|
||||
cmd = ["openscad", "-o", "/dev/null", "--export-format=asciistl", *defs, generator]
|
||||
proc = subprocess.run(cmd, cwd=SCAD_DIR, capture_output=True, text=True, timeout=900)
|
||||
text = proc.stdout + proc.stderr
|
||||
|
||||
report: dict = {}
|
||||
rejection: str | None = None
|
||||
for line in text.splitlines():
|
||||
if line.startswith('ECHO: "SB_') and "=" in line:
|
||||
key, _, value = line[len('ECHO: "SB_'):].rstrip('"').partition("=")
|
||||
if key == "END":
|
||||
continue
|
||||
if key in NUM:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
report[key] = value
|
||||
elif "[strap-beam]" in line:
|
||||
rejection = line.split("] ", 1)[-1].strip()
|
||||
return report, rejection
|
||||
|
||||
|
||||
def regenerate() -> int:
|
||||
cases = build_cases()
|
||||
results = []
|
||||
for i, c in enumerate(cases, 1):
|
||||
defs = [f'-Dprofile_type="{c["profile"]}"'] + c["defs"]
|
||||
report, rejection = run_case(c["generator"], defs)
|
||||
results.append({
|
||||
**c,
|
||||
"defs": defs,
|
||||
"outcome": "ok" if report else "rejected",
|
||||
"report": report or None,
|
||||
"rejection": rejection,
|
||||
})
|
||||
print(f" [{i:3d}/{len(cases)}] {c['profile']:22s} {c['label']:16s} "
|
||||
f"{'ok' if report else 'REJECTED'}", file=sys.stderr)
|
||||
|
||||
accepted = sum(1 for r in results if r["outcome"] == "ok")
|
||||
doc = {
|
||||
"schema": "strap-beam.fixtures/1",
|
||||
"generator_revision": REVISION,
|
||||
"frozen": datetime.date.today().isoformat(),
|
||||
"toolchain": {"openscad": OPENSCAD_VERSION, "bosl2_commit": BOSL2_COMMIT},
|
||||
"note": (
|
||||
"Acceptance oracle for any reimplementation of the rev-8 generators. "
|
||||
"MIN_WALL_ACTUAL_MM, SECTION_PARTS and STRAP_CHANNELS are the invariants; "
|
||||
"the solved values (RING_*, FIN_*, AF_*, SPOKE_*) pin the placement solvers. "
|
||||
"Rejected cases are part of the contract: a port that accepts them is wrong."
|
||||
),
|
||||
"tolerance": {"lengths_mm": 1e-4, "areas_mm2": 1e-3, "note": "counts are exact"},
|
||||
"summary": {"cases": len(results), "accepted": accepted,
|
||||
"rejected": len(results) - accepted},
|
||||
"cases": results,
|
||||
}
|
||||
doc["fixtures_sha256"] = hashlib.sha256(
|
||||
json.dumps(doc, indent=2, sort_keys=True).encode()
|
||||
).hexdigest()
|
||||
OUT.write_text(json.dumps(doc, indent=2, sort_keys=True))
|
||||
|
||||
print(f"\n{len(results)} cases: {accepted} accepted, {len(results) - accepted} rejected")
|
||||
print("sha256:", doc["fixtures_sha256"])
|
||||
return 0
|
||||
|
||||
|
||||
def verify() -> int:
|
||||
if not OUT.exists():
|
||||
print(f"MISSING: {OUT}", file=sys.stderr)
|
||||
return 2
|
||||
doc = json.loads(OUT.read_text())
|
||||
recorded = doc.pop("fixtures_sha256", None)
|
||||
actual = hashlib.sha256(json.dumps(doc, indent=2, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
print(f"file: {OUT.name}")
|
||||
print(f"revision: {doc.get('generator_revision')}")
|
||||
print(f"toolchain: openscad {doc['toolchain']['openscad']}, "
|
||||
f"bosl2 {doc['toolchain']['bosl2_commit'][:10]}")
|
||||
print(f"summary: {doc['summary']}")
|
||||
print(f"recorded: {recorded}")
|
||||
print(f"computed: {actual}")
|
||||
|
||||
if recorded == actual:
|
||||
print("\nOK - oracle is intact.")
|
||||
return 0
|
||||
print("\nFAIL - the oracle has been modified. A drifting oracle is worse "
|
||||
"than no oracle. Investigate before proceeding.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--verify", action="store_true", help="check the committed oracle")
|
||||
g.add_argument("--regenerate", action="store_true",
|
||||
help="rebuild it; requires OpenSCAD and BOSL2")
|
||||
args = ap.parse_args()
|
||||
return regenerate() if args.regenerate else verify()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user