From e921cacf0e52a4eb024a72be7499e56dc76da504 Mon Sep 17 00:00:00 2001 From: TheRON Date: Mon, 24 Aug 2026 03:58:12 -0500 Subject: [PATCH] design record: every build emits one Result gains a record field with a default. Nothing existing moves and the report dict is untouched, so all 123 oracle cases are unmoved at 506 passed. A rejected profile still raises before this point and gets no record, which is correct because there is no model to preserve. The record learns the stock and fit from the Geo the build actually used, so it describes what was built rather than what was asked for. Those agree today and the record will keep saying so if they ever stop. code_revision resolves once per process from MECHCOMP_REVISION, then git rev-parse, then unknown. Never a guess: unknown tells you the part may not be reproducible, while a plausible wrong sha would send someone to the wrong commit. A dirty tree is reported as such, since a model built from uncommitted edits cannot be regenerated from a revision alone. Caching matters because assemble runs 123 times in the suite and would otherwise shell out to git twice per build. The end to end test builds a part, renders its record, parses it back from nothing but the text, rebuilds from the parsed parameters, and asserts the report is identical key for key across five profiles. If that fails the record is a description rather than a recipe. Method correction. The mutation harness had been letting pytest write bytecode, and a write landing inside one mtime tick could be masked by a stale pyc. That made one real defect appear to survive and, more importantly, means every mutation result reported earlier in this work was optimistic by an unknown amount. Rerun with bytecode disabled, all nine mutations caught. The earlier sets are worth rerunning under the corrected method. --- src/mechcomp/design_record.py | 54 +++++++++++- src/mechcomp/geom/report.py | 13 ++- src/mechcomp/profiles/_common.py | 45 +++++++++- tests/test_design_record.py | 147 +++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 3 deletions(-) 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"