diff --git a/src/mechcomp/design_record.py b/src/mechcomp/design_record.py new file mode 100644 index 0000000..f9543ac --- /dev/null +++ b/src/mechcomp/design_record.py @@ -0,0 +1,367 @@ +""" +The design record: what a model was made from, emitted with it. + +WHY THIS EXISTS + The compiler is parametric, which means every dimension is an input at every + run and you are expected to tune them: print, measure the print, adjust, + print again. That loop is the tool working correctly. + + It is also the loop that destroys the value of everything already printed. + Tune a clearance from 0.25 to 0.15 and the parts on the bench become + unidentifiable -- you cannot tell which setting produced which part, cannot + print a matching second one, and cannot reproduce the good one after the + settings have moved on. Configurability and reproducibility are in direct + conflict unless something records what each part was made from. + + That something is this. A design record travels with a generated model and + carries the fully resolved input set at full precision, the stock and fit + descriptions with whatever provenance exists, the code that consumed them, + and an identity hash. It is the same discipline as the frozen oracle, turned + around to face the output instead of the reference. + +WHAT IT IS NOT BUILT FROM + Not the report. ``geom.report`` rounds every number to six significant + figures because that is what OpenSCAD's ``echo`` printed and the oracle + records what was printed. Six figures is lossy: a part cannot be regenerated + from ``SECTION_AREA_MM2 = 135.574``. + + What regenerates a model is its INPUTS -- family, profile, and the resolved + parameter set, stored at full precision with ``repr`` so they round-trip + exactly. Reported values are carried too, but as VERIFICATION: numbers a + caliper on the actual part can be checked against. Inputs reproduce; + outputs confirm. + +TWO IDENTITIES, BECAUSE THEY ANSWER DIFFERENT QUESTIONS + ``input_id`` the design intent: family, profile, parameters. Two records + with the same input_id describe the same part as specified. + + ``build_id`` input_id plus the code revision and toolchain. Two records + with the same build_id should produce identical bytes. + + Keeping them apart matters. When the code changes and the input_id still + matches, you know the intent is unchanged and the geometry MIGHT have moved + -- which is exactly the moment to check, rather than to assume either way. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Dict, List, Mapping, Optional, Tuple + +FORMAT_VERSION = "1" + +# Reported values worth carrying for verification: the ones a person can put a +# caliper on, plus the counts that say the part is what it claims to be. Kept +# short deliberately -- a record nobody reads verifies nothing. +VERIFY_KEYS: Tuple[str, ...] = ( + "ENVELOPE_X_MM", + "ENVELOPE_Y_MM", + "MIN_WALL_ACTUAL_MM", + "MIN_WALL_SPEC_MM", + "SECTION_PARTS", + "STRAP_CHANNELS", + "SECTION_AREA_MM2", + "MASS_G", + "LENGTH_MM", +) + + +# --------------------------------------------------------------------------- +# Canonical form +# --------------------------------------------------------------------------- + +def canonical_value(value: object) -> str: + """ + A value's exact textual form. + + ``repr`` on a float round-trips exactly in Python 3, which is the whole + point: ``float(repr(x)) == x`` for every finite x, so a record can be read + back and rebuilt without drift. ``str`` and ``%g`` both lose bits and would + make the record a description of the part rather than a recipe for it. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return repr(value) + if isinstance(value, int): + return repr(value) + if isinstance(value, (list, tuple)): + return "[%s]" % ", ".join(canonical_value(v) for v in value) + return str(value) + + +def parse_value(text: str) -> object: + """Inverse of ``canonical_value`` for the scalar cases a parameter can hold.""" + if text == "true": + return True + if text == "false": + return False + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + pass + return text + + +def canonical_params(params: Mapping[str, object]) -> str: + """ + Sorted ``key=value`` lines. Sorted so that dict ordering -- which depends on + how a caller happened to build the mapping -- cannot change the identity of + a design that is otherwise the same. + """ + return "\n".join("%s=%s" % (k, canonical_value(params[k])) + for k in sorted(params)) + + +def resolve(defaults: Mapping[str, object], + params: Optional[Mapping[str, object]] = None) -> Dict[str, object]: + """ + The resolved parameter set, exactly as ``_common.assemble`` computes it. + + ``params`` carries only a case's overrides; everything else comes from the + family defaults. Reproducing that merge here rather than importing it keeps + this module off the build path -- nothing in a profile depends on the design + record existing -- at the cost of one line that must stay in step. A test + asserts it does. + """ + return {**dict(defaults), **dict(params or {})} + + +# --------------------------------------------------------------------------- +# The record +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class DesignRecord: + """ + Everything needed to identify, verify and regenerate one model. + + ``generated`` is deliberately excluded from both hashes. A record made today + and the same record remade tomorrow describe the same part, and if the + timestamp entered the identity then regenerating a design would always + appear to produce a different one. + """ + + family: str + profile: str + params: Dict[str, object] + + code_revision: str = "unknown" + generator_revision: str = "unknown" + toolchain: Dict[str, str] = field(default_factory=dict) + + stock: List[str] = field(default_factory=list) + fit: List[str] = field(default_factory=list) + + verification: Dict[str, object] = field(default_factory=dict) + generated: str = "" + note: str = "" + + # -- identity ---------------------------------------------------------- + + @property + def input_canonical(self) -> str: + return "\n".join([ + "format=%s" % FORMAT_VERSION, + "family=%s" % self.family, + "profile=%s" % self.profile, + "params:", + canonical_params(self.params), + ]) + + @property + def build_canonical(self) -> str: + tools = "\n".join("%s=%s" % (k, self.toolchain[k]) + for k in sorted(self.toolchain)) + return "\n".join([ + self.input_canonical, + "code_revision=%s" % self.code_revision, + "generator_revision=%s" % self.generator_revision, + "toolchain:", + tools, + ]) + + @property + def input_id(self) -> str: + return hashlib.sha256(self.input_canonical.encode("utf-8")).hexdigest()[:16] + + @property + def build_id(self) -> str: + return hashlib.sha256(self.build_canonical.encode("utf-8")).hexdigest()[:16] + + # -- rendering --------------------------------------------------------- + + def render(self) -> str: + """ + The record as it ships beside a model. + + Plain text, one fact per line, readable without this software. A record + that can only be opened by the program that wrote it is not much of a + record -- the point is that it still means something in five years, on a + machine where none of this is installed. + """ + lines: List[str] = [ + "MECHCOMP DESIGN RECORD %s" % FORMAT_VERSION, + "", + "input_id %s design intent: family, profile, parameters" + % self.input_id, + "build_id %s intent plus the code and toolchain that consumed it" + % self.build_id, + "", + "family %s" % self.family, + "profile %s" % self.profile, + ] + if self.generated: + lines.append("generated %s (excluded from both ids)" % self.generated) + if self.note: + lines.append("note %s" % self.note) + + lines += ["", "-- stock ------------------------------------------------"] + lines += [" %s" % s for s in (self.stock or ["(none recorded)"])] + + lines += ["", "-- fit --------------------------------------------------"] + lines += [" %s" % f for f in (self.fit or ["(none recorded)"])] + + lines += ["", "-- parameters, full precision, sufficient to regenerate --"] + for k in sorted(self.params): + lines.append(" %s=%s" % (k, canonical_value(self.params[k]))) + + lines += ["", "-- build ------------------------------------------------", + " code_revision=%s" % self.code_revision, + " generator_revision=%s" % self.generator_revision] + for k in sorted(self.toolchain): + lines.append(" %s=%s" % (k, self.toolchain[k])) + + if self.verification: + lines += ["", "-- verification: measure the print against these -------"] + for k in sorted(self.verification): + lines.append(" %s=%s" % (k, canonical_value(self.verification[k]))) + lines += ["", + " These are the compiler's own reported values, rounded to six", + " significant figures as the report emits them. They confirm a", + " part; they do not regenerate one. The parameters above do."] + + return "\n".join(lines) + "\n" + + # -- reading back ------------------------------------------------------ + + @staticmethod + def parse(text: str) -> "DesignRecord": + """ + Read a rendered record back. + + Only the fields that reproduce a design are recovered -- family, profile, + parameters, build identity. Verification values are outputs and are + deliberately not fed back in as inputs. + """ + family = profile = "" + code_revision = generator_revision = "unknown" + params: Dict[str, object] = {} + toolchain: Dict[str, str] = {} + section = "" + + for raw in text.splitlines(): + line = raw.rstrip() + if line.startswith("-- parameters"): + section = "params" + continue + if line.startswith("-- build"): + section = "build" + continue + if line.startswith("-- "): + section = "" + continue + + if section == "params" and line.startswith(" ") and "=" in line: + key, _, value = line.strip().partition("=") + params[key] = parse_value(value) + elif section == "build" and line.startswith(" ") and "=" in line: + key, _, value = line.strip().partition("=") + if key == "code_revision": + code_revision = value + elif key == "generator_revision": + generator_revision = value + else: + toolchain[key] = value + elif line.startswith("family "): + family = line.split(None, 1)[1].strip() + elif line.startswith("profile "): + profile = line.split(None, 1)[1].strip() + + return DesignRecord( + family=family, profile=profile, params=params, + code_revision=code_revision, + generator_revision=generator_revision, + toolchain=toolchain, + ) + + +# --------------------------------------------------------------------------- +# Construction from a build +# --------------------------------------------------------------------------- + +def toolchain_versions() -> Dict[str, str]: + """ + Versions that can change emitted geometry. + + Shapely and GEOS are here because HANDOFF section 5 records that boolean + results on near-degenerate geometry can shift between GEOS releases -- which + is precisely the F-034 mechanism. A record that omits them cannot explain why + the same parameters produced a different part. + """ + out: Dict[str, str] = {} + try: + import shapely + out["shapely"] = shapely.__version__ + try: + out["geos"] = shapely.geos_version_string + except AttributeError: + pass + except Exception: # noqa: BLE001 + out["shapely"] = "unavailable" + try: + import numpy + out["numpy"] = numpy.__version__ + except Exception: # noqa: BLE001 + pass + return out + + +def record_for(family: str, profile: str, + defaults: Mapping[str, object], + params: Optional[Mapping[str, object]] = None, + report: Optional[Mapping[str, object]] = None, + stock: Optional[List[str]] = None, + fit: Optional[List[str]] = None, + code_revision: str = "unknown", + generator_revision: str = "unknown", + generated: str = "", + note: str = "") -> DesignRecord: + """ + Build a record for one design. + + ``defaults`` comes from the family (``three_x.FAMILY.defaults``), ``params`` + is the override set, and the merge reproduces what ``assemble`` does. + """ + verification: Dict[str, object] = {} + if report: + verification = {k: report[k] for k in VERIFY_KEYS if k in report} + + return DesignRecord( + family=family, + profile=profile, + params=resolve(defaults, params), + code_revision=code_revision, + generator_revision=generator_revision, + toolchain=toolchain_versions(), + stock=list(stock or []), + fit=list(fit or []), + verification=verification, + generated=generated, + note=note, + ) diff --git a/tests/test_design_record.py b/tests/test_design_record.py new file mode 100644 index 0000000..5e52aaf --- /dev/null +++ b/tests/test_design_record.py @@ -0,0 +1,278 @@ +""" +The design record has to survive being the only thing left. + +A part on the bench, a text file beside it, and five years. If the record cannot +regenerate the design from that, it is decoration. These tests are mostly about +exactness and about what must NOT change an identity. +""" + +from __future__ import annotations + +import pytest + +from mechcomp.design_record import ( + FORMAT_VERSION, + VERIFY_KEYS, + DesignRecord, + canonical_params, + canonical_value, + parse_value, + record_for, + resolve, + toolchain_versions, +) +from mechcomp.profiles.three_x import FAMILY as THREE_X + + +def _record(**over): + base = dict( + family="3x", profile="Y", + defaults=THREE_X.defaults, + params={"fit_clearance_mm": 0.25}, + code_revision="abc1234", + generator_revision="8.0.0", + ) + base.update(over) + return record_for(**base) + + +# --------------------------------------------------------------------------- +# Exactness -- the record is a recipe, not a description +# --------------------------------------------------------------------------- + +def test_floats_round_trip_exactly(): + """ + Every float must survive render -> parse unchanged, bit for bit. + + This is the property the whole record rests on. ``%g`` at six significant + figures -- what the report emits -- would lose bits here and turn the record + into a description of the part rather than a means of rebuilding it. + """ + hostile = [0.1, 0.2, 0.30000000000000004, 1 / 3, 15.875, 0.508, 13.4, 0.79, + 1e-9, 1.7976931348623157e308, 5e-324, 2.2250738585072014e-308, + 123456.789012345, 0.1 + 0.2] + for value in hostile: + assert float(canonical_value(value)) == value, value + assert parse_value(canonical_value(value)) == value, value + + rec = _record(params={"fit_clearance_mm": 0.1 + 0.2, "strap_width_mm": 1 / 3}) + back = DesignRecord.parse(rec.render()) + assert back.params["fit_clearance_mm"] == 0.1 + 0.2 + assert back.params["strap_width_mm"] == 1 / 3 + + +def test_render_parse_round_trip_preserves_identity(): + """A record written out and read back is the same design.""" + rec = _record() + back = DesignRecord.parse(rec.render()) + assert back.family == rec.family + assert back.profile == rec.profile + assert back.params == rec.params + assert back.input_id == rec.input_id + assert back.code_revision == rec.code_revision + assert back.generator_revision == rec.generator_revision + assert back.toolchain == rec.toolchain + assert back.build_id == rec.build_id + + +def test_resolve_matches_the_assemble_merge(): + """ + ``resolve`` reproduces ``_common.assemble``'s ``{**defaults, **params}``. + + Duplicated deliberately, to keep this module off the build path. Duplication + is only safe while it is checked, so it is checked. + """ + params = {"fit_clearance_mm": 0.4, "y_junction_web_mm": 2.0} + got = resolve(THREE_X.defaults, params) + want = {**THREE_X.defaults, **params} + assert got == want + assert got["fit_clearance_mm"] == 0.4 + assert got["strap_width_mm"] == THREE_X.defaults["strap_width_mm"] + assert set(got) == set(THREE_X.defaults) + + +# --------------------------------------------------------------------------- +# Identity -- what must and must not change it +# --------------------------------------------------------------------------- + +def test_identical_designs_have_identical_ids(): + assert _record().input_id == _record().input_id + assert _record().build_id == _record().build_id + + +def test_parameter_order_does_not_change_identity(): + """Dict ordering is an accident of how a caller built the mapping.""" + a = record_for(family="3x", profile="Y", defaults={}, + params={"a": 1.0, "b": 2.0, "c": 3.0}) + b = record_for(family="3x", profile="Y", defaults={}, + params={"c": 3.0, "b": 2.0, "a": 1.0}) + assert a.input_id == b.input_id + assert canonical_params(a.params) == canonical_params(b.params) + + +def test_any_parameter_change_changes_the_input_id(): + """The tuning loop must never silently reuse an identity.""" + base = _record() + seen = {base.input_id} + for clearance in (0.15, 0.2, 0.24, 0.2500001, 0.26, 0.0, -0.05): + other = _record(params={"fit_clearance_mm": clearance}) + assert other.input_id != base.input_id, clearance + assert other.input_id not in seen, "collision at %r" % clearance + seen.add(other.input_id) + + assert _record(profile="T").input_id != base.input_id + assert _record(family="4x").input_id != base.input_id + + +def test_code_revision_changes_build_id_but_not_input_id(): + """ + The distinction that makes the pair useful: same intent, different bytes + possible. That is the moment to check whether the geometry moved, and the + record is what tells you to look. + """ + a = _record(code_revision="aaaaaaa") + b = _record(code_revision="bbbbbbb") + assert a.input_id == b.input_id + assert a.build_id != b.build_id + + c = _record(generator_revision="8.0.1") + assert c.input_id == a.input_id + assert c.build_id != a.build_id + + +def test_timestamp_and_note_are_excluded_from_both_ids(): + """ + A design regenerated tomorrow is the same design. If the clock entered the + identity, nothing would ever reproduce. + """ + a = _record(generated="2026-08-23T10:00:00Z", note="first attempt") + b = _record(generated="2027-01-01T00:00:00Z", note="reprint for Dave") + assert a.input_id == b.input_id + assert a.build_id == b.build_id + assert "2026-08-23" in a.render() + assert "first attempt" in a.render() + + +def test_verification_values_do_not_affect_identity(): + """ + Reported values are outputs. If they entered the hash, a part would be + identified by what it turned out to be rather than by what was asked for -- + and two runs of the same design on different GEOS builds could then claim to + be different designs. + """ + report = {k: 1.0 for k in VERIFY_KEYS} + other = {k: 2.0 for k in VERIFY_KEYS} + a = _record(report=report) + b = _record(report=other) + c = _record() + assert a.input_id == b.input_id == c.input_id + assert a.build_id == b.build_id == c.build_id + assert a.verification != b.verification + + +# --------------------------------------------------------------------------- +# The rendered artifact +# --------------------------------------------------------------------------- + +def test_render_is_deterministic(): + assert _record().render() == _record().render() + + +def test_render_is_readable_without_this_software(): + """ + Plain text, one fact per line, no framing that needs a parser. A record that + only this program can open is not a record. + """ + rec = _record(report={k: 12.3456 for k in VERIFY_KEYS}, + stock=["PET strap 15.875 x 0.508 -- unverified: supplied at design time"], + fit=["clearance 0.2500 mm per face"], + generated="2026-08-23T10:00:00Z") + text = rec.render() + + assert text.startswith("MECHCOMP DESIGN RECORD %s" % FORMAT_VERSION) + assert rec.input_id in text and rec.build_id in text + assert "3x" in text and "Y" in text + assert "PET strap" in text and "unverified" in text + assert "clearance 0.2500 mm per face" in text + assert "fit_clearance_mm=0.25" in text + assert "code_revision=abc1234" in text + assert "generator_revision=8.0.0" in text + assert "ENVELOPE_X_MM" in text + assert text.endswith("\n") + + # Every resolved parameter reaches the file. A record that carries only the + # overrides cannot rebuild anything, because the defaults it silently relied + # on will have moved by then. + for key in THREE_X.defaults: + assert key in text, "parameter %s missing from the record" % key + + +def test_record_carries_every_resolved_parameter_not_just_overrides(): + rec = _record(params={"fit_clearance_mm": 0.25}) + assert set(rec.params) == set(THREE_X.defaults) + assert len(rec.params) > 20 + + +def test_unrecorded_stock_and_fit_say_so_rather_than_being_blank(): + text = _record().render() + assert "(none recorded)" in text + + +def test_toolchain_is_captured_and_reaches_the_build_id(): + """ + GEOS can change boolean results on near-degenerate geometry -- the F-034 + mechanism exactly. A record omitting it cannot explain why the same + parameters produced a different part, so its absence must be a failure and + not a quiet blank. + """ + shapely = pytest.importorskip( + "shapely", + reason="the toolchain assertion is about capturing the real GEOS " + "version; without shapely there is nothing to capture") + + tools = toolchain_versions() + assert "shapely" in tools, "shapely version not captured" + assert tools["shapely"] == shapely.__version__ + assert tools["shapely"] != "unavailable", "shapely version lookup failed" + + rec = _record() + assert rec.toolchain == tools + assert rec.toolchain.get("shapely") + + text = rec.render() + assert "shapely=%s" % tools["shapely"] in text + + # A different toolchain is a different build, even at identical intent. + same_intent = DesignRecord( + family=rec.family, profile=rec.profile, params=dict(rec.params), + code_revision=rec.code_revision, + generator_revision=rec.generator_revision, + toolchain={**tools, "shapely": "0.0.0-not-real"}) + assert same_intent.input_id == rec.input_id + assert same_intent.build_id != rec.build_id + + empty = DesignRecord(family=rec.family, profile=rec.profile, + params=dict(rec.params), + code_revision=rec.code_revision, + generator_revision=rec.generator_revision, + toolchain={}) + assert empty.build_id != rec.build_id + + +def test_missing_report_keys_are_omitted_not_invented(): + rec = _record(report={"ENVELOPE_X_MM": 20.5}) + assert rec.verification == {"ENVELOPE_X_MM": 20.5} + assert "MASS_G" not in rec.verification + + +def test_bools_survive_the_round_trip(): + """``length_view`` is a string and some profile extras are bools.""" + assert canonical_value(True) == "true" + assert parse_value("true") is True + assert parse_value("false") is False + rec = record_for(family="3x", profile="Y", defaults={}, + params={"flag": True, "off": False, "name": "Preview"}) + back = DesignRecord.parse(rec.render()) + assert back.params["flag"] is True + assert back.params["off"] is False + assert back.params["name"] == "Preview"