diff --git a/src/mechcomp/design_record.py b/src/mechcomp/design_record.py index f9543ac..db303bf 100644 --- a/src/mechcomp/design_record.py +++ b/src/mechcomp/design_record.py @@ -46,11 +46,17 @@ TWO IDENTITIES, BECAUSE THEY ANSWER DIFFERENT QUESTIONS from __future__ import annotations import hashlib +import os from dataclasses import dataclass, field from typing import Dict, List, Mapping, Optional, Tuple FORMAT_VERSION = "1" +# Resolved once per process. Both are stable for the life of a run and +# looking them up per build would put a subprocess in the build path. +_CODE_REVISION = None +_TOOLCHAIN = None + # 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. @@ -305,6 +311,47 @@ class DesignRecord: # Construction from a build # --------------------------------------------------------------------------- +def code_revision() -> str: + """ + The revision of the code that produced a model. + + Resolved once per process, in order: an explicit ``MECHCOMP_REVISION`` + in the environment, then ``git rev-parse`` against the package's own + tree, then ``"unknown"``. + + Never guessed. An "unknown" in a record is honest and tells you the + part may not be reproducible; a plausible wrong revision would send + someone to the wrong commit, which is worse. A dirty working tree is + reported as such, because a model built from uncommitted edits cannot + be regenerated from the revision alone. + """ + global _CODE_REVISION + if _CODE_REVISION is not None: + return _CODE_REVISION + + explicit = os.environ.get("MECHCOMP_REVISION", "").strip() + if explicit: + _CODE_REVISION = explicit + return _CODE_REVISION + + _CODE_REVISION = "unknown" + try: + import subprocess + + here = os.path.dirname(os.path.abspath(__file__)) + sha = subprocess.run( + ["git", "-C", here, "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=5).stdout.strip() + if sha: + dirty = subprocess.run( + ["git", "-C", here, "status", "--porcelain"], + capture_output=True, text=True, timeout=5).stdout.strip() + _CODE_REVISION = sha + ("-dirty" if dirty else "") + except Exception: # noqa: BLE001 + pass + return _CODE_REVISION + + def toolchain_versions() -> Dict[str, str]: """ Versions that can change emitted geometry. @@ -314,6 +361,10 @@ def toolchain_versions() -> Dict[str, str]: is precisely the F-034 mechanism. A record that omits them cannot explain why the same parameters produced a different part. """ + global _TOOLCHAIN + if _TOOLCHAIN is not None: + return dict(_TOOLCHAIN) + out: Dict[str, str] = {} try: import shapely @@ -329,7 +380,8 @@ def toolchain_versions() -> Dict[str, str]: out["numpy"] = numpy.__version__ except Exception: # noqa: BLE001 pass - return out + _TOOLCHAIN = out + return dict(out) def record_for(family: str, profile: str, diff --git a/src/mechcomp/geom/report.py b/src/mechcomp/geom/report.py index d5a59d5..53630af 100644 --- a/src/mechcomp/geom/report.py +++ b/src/mechcomp/geom/report.py @@ -244,8 +244,19 @@ def report(family: str, profile: str, status: str, g: Geo, m: Metrics, @dataclass(frozen=True) class Result: - """What ``build()`` returns. ``report`` is the dict the oracle compares.""" + """ + What ``build()`` returns. ``report`` is the dict the oracle compares. + + ``record`` is the design record: what this model was made from, at full + precision, sufficient to regenerate it. It defaults to ``None`` so that + a ``Result`` can still be constructed without one -- but every build + through ``assemble`` carries it, because a generated model with no + record becomes unidentifiable the moment a parameter is tuned. + + It is not part of the oracle comparison. ``report`` is unchanged. + """ report: Dict[str, object] section: List[List[Tuple[float, float]]] members: List[Member] geo: Geo + record: object = None diff --git a/src/mechcomp/profiles/_common.py b/src/mechcomp/profiles/_common.py index 8d15d68..df8b669 100644 --- a/src/mechcomp/profiles/_common.py +++ b/src/mechcomp/profiles/_common.py @@ -56,6 +56,48 @@ class Family: base_checks: Callable[[Params], List[Check]] +def _design_record(family: Family, profile_type: str, params: Params, + geo: Geo, rep: Dict[str, object]): + """ + The record that ships with this model. + + Built from what the build already had: the family defaults, the caller's + overrides, and the Geo that was actually used. The stock and fit lines + come from that Geo rather than from the parameters, so they describe what + was built rather than what was asked for -- the two agree today and the + record should keep saying so if they ever stop. + + Imported lazily. ``mechcomp.design_record`` is a leaf that imports + nothing from mechcomp, and keeping the import here rather than at module + scope means a failure in the record can never prevent a part being built. + """ + from mechcomp.design_record import code_revision, record_for + from mechcomp.stock import Fit, Provenance, RectStock + + stock = RectStock( + designation="stock", + width=geo.width, thickness=geo.strap_t, count=geo.count, + provenance=Provenance( + source="build parameters", + note="strap_width_mm and strap_thickness_mm as supplied")) + fit = Fit(clearance=geo.clearance) + + return record_for( + family=family.name, + profile=profile_type, + defaults=family.defaults, + params=params, + report=rep, + stock=["%s %g x %g mm x%d, stack %g mm -- %s" + % (stock.designation, stock.width, stock.thickness, + stock.count, stock.stack, + stock.provenance.describe())], + fit=[fit.describe()], + code_revision=code_revision(), + generator_revision=REVISION, + ) + + # --------------------------------------------------------------------------- # Pieces every family shares # --------------------------------------------------------------------------- @@ -127,7 +169,8 @@ def assemble(family: Family, profile_type: str, params: Params) -> Result: model_length_mm(p), p["material_density_g_cm3"], extra, ) - return Result(report=rep, section=c.section, members=c.members, geo=geo) + return Result(report=rep, section=c.section, members=c.members, geo=geo, + record=_design_record(family, profile_type, params, geo, rep)) def geometry_checks(p: Params) -> List[Check]: diff --git a/tests/test_design_record.py b/tests/test_design_record.py index 5e52aaf..1d40f00 100644 --- a/tests/test_design_record.py +++ b/tests/test_design_record.py @@ -265,6 +265,153 @@ def test_missing_report_keys_are_omitted_not_invented(): assert "MASS_G" not in rec.verification +def test_build_emits_a_record_that_regenerates_the_same_report(): + """ + The end-to-end claim, asserted rather than argued. + + Build something. Render its record. Read the record back with nothing but + the text. Rebuild from the parsed parameters. The report must be + identical, key for key. If it is not, the record is a description of a + part rather than a means of rebuilding it, and preservation is a claim we + cannot make. + """ + build = pytest.importorskip("mechcomp.profiles").build + + for profile, params in ( + ("Y", {}), + ("Y", {"fit_clearance_mm": 0.15}), + ("T", {"t_stem_web_mm": 1.6, "bundle_count": 2}), + ("Three-Fin", {"three_fin_bore_side_mm": 15.0}), + ("A Frame", {"a_frame_leg_angle_deg": 53}), + ): + first = build(family="3x", profile=profile, params=params) + assert first.record is not None, "build produced no design record" + + text = first.record.render() + recovered = type(first.record).parse(text) + + assert recovered.input_id == first.record.input_id + assert recovered.family == "3x" + assert recovered.profile == profile + + # Rebuild using ONLY what came out of the text. + again = build(family=recovered.family, profile=recovered.profile, + params=recovered.params) + + assert again.report == first.report, ( + "regenerating %s from its own design record produced a different " + "report" % profile) + assert again.record.input_id == first.record.input_id + assert again.record.build_id == first.record.build_id + + +def test_tuning_a_parameter_changes_the_identity_of_the_part(): + """ + The failure this whole module exists to prevent: two prints at different + settings that cannot be told apart afterwards. + """ + build = pytest.importorskip("mechcomp.profiles").build + + seen = {} + for clearance in (0.15, 0.2, 0.25, 0.3): + r = build(family="3x", profile="Y", + params={"fit_clearance_mm": clearance}) + ident = r.record.input_id + assert ident not in seen, ( + "clearance %s and %s produced the same input_id -- two different " + "parts would be indistinguishable" % (clearance, seen.get(ident))) + seen[ident] = clearance + assert "fit_clearance_mm=%r" % clearance in r.record.render() + + +def test_record_describes_the_stock_and_fit_actually_used(): + build = pytest.importorskip("mechcomp.profiles").build + + r = build(family="3x", profile="Y", + params={"strap_width_mm": 13.4, "fit_clearance_mm": 0.15, + "bundle_count": 2}) + text = r.record.render() + + # Target the stock line itself. Asserting "13.4" appears somewhere + # in the record is not enough -- it also appears as strap_width_mm in + # the parameter block, so a designation hardcoded to the family + # default would pass unnoticed. + stock_lines = [l.strip() for l in text.splitlines() + if l.strip().startswith("stock ")] + assert stock_lines, "no stock line in the record" + assert stock_lines[0].startswith("stock 13.4 x 0.508 mm x2, stack 1.016 mm"), ( + stock_lines) + + assert "clearance 0.1500 mm per face" in text + assert "build parameters" in text + + +def test_a_rejected_profile_produces_no_model_and_no_record(): + """There is nothing to preserve, and the rejection is unchanged.""" + profiles = pytest.importorskip("mechcomp.profiles") + + with pytest.raises(profiles.ProfileRejected): + profiles.build(family="3x", profile="Y", + params={"min_wall_mm": 0}) + + +def test_code_revision_is_resolved_or_honestly_unknown(): + """ + Never a guess. "unknown" tells you the part may not be reproducible; a + plausible wrong revision would send someone to the wrong commit. + """ + from mechcomp.design_record import code_revision + + rev = code_revision() + assert rev + assert rev == code_revision(), "code_revision is not stable per process" + if rev != "unknown": + head = rev[:-len("-dirty")] if rev.endswith("-dirty") else rev + assert len(head) >= 7 + assert all(c in "0123456789abcdef" for c in head), rev + + +def test_code_revision_honours_an_explicit_revision(monkeypatch): + import mechcomp.design_record as dr + + monkeypatch.setattr(dr, "_CODE_REVISION", None) + monkeypatch.setenv("MECHCOMP_REVISION", "deadbee") + assert dr.code_revision() == "deadbee" + + +def test_code_revision_is_cached_not_recomputed(monkeypatch): + # Caching is not a micro-optimisation. Without it every build shells + # out to git twice, and assemble runs 123 times in the acceptance + # suite alone -- a subprocess in a geometry compilers build path. + import mechcomp.design_record as dr + + monkeypatch.setattr(dr, "_CODE_REVISION", None) + monkeypatch.setenv("MECHCOMP_REVISION", "first00") + assert dr.code_revision() == "first00" + + monkeypatch.setenv("MECHCOMP_REVISION", "second0") + assert dr.code_revision() == "first00", ( + "code_revision recomputed instead of using its cache") + + +def test_code_revision_falls_back_to_unknown_not_a_fabrication(monkeypatch): + # When git cannot answer, the record must say so. A plausible wrong + # sha would send someone to the wrong commit, which is worse than + # admitting the part may not be reproducible. + import subprocess + + import mechcomp.design_record as dr + + monkeypatch.setattr(dr, "_CODE_REVISION", None) + monkeypatch.delenv("MECHCOMP_REVISION", raising=False) + + def refuse(*a, **k): + raise OSError("git unavailable") + + monkeypatch.setattr(subprocess, "run", refuse) + assert dr.code_revision() == "unknown" + + def test_bools_survive_the_round_trip(): """``length_view`` is a string and some profile extras are bools.""" assert canonical_value(True) == "true"