design record: what a model was made from, sufficient to regenerate it
A parametric compiler is meant to be tuned: print, measure the print, adjust, print again. That loop also destroys the value of everything already printed, because once a clearance moves the parts on the bench become unidentifiable and unreproducible. Configurability and reproducibility conflict unless something records what each part was made from. The record carries the fully resolved parameter set at full precision, the stock and fit lines with whatever provenance exists, the code revision and the toolchain. Plain text, one fact per line, readable without this software. It is deliberately not built from the report. geom.report rounds to six significant figures because that is what echo printed and the oracle records what was printed. A part cannot be regenerated from SECTION_AREA_MM2 equals 135.574. Inputs reproduce, outputs confirm: reported values are carried separately as verification, to be checked with a caliper against the actual part. Two identities. input_id covers family, profile and parameters, the design intent. build_id adds code revision and toolchain. Shapely and GEOS are in build_id because boolean results on near degenerate geometry can shift between GEOS releases, which is the F-034 mechanism, and a record omitting them could not explain why the same numbers produced a different part. Timestamp, note and verification values are excluded from both. Not on the build path. No profile imports it, so the frozen oracle cannot move. Twelve mutations tested and caught, including two defects of mine: the record carrying only overrides rather than the resolved set, and the toolchain never reaching build_id.
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user