diff --git a/docs/STOCK.md b/docs/STOCK.md index d988bcb..004dff4 100644 --- a/docs/STOCK.md +++ b/docs/STOCK.md @@ -121,9 +121,24 @@ which number was used. Geometry takes millimetres. part encloses a conduit says nothing about what the assembly carries. Measure and attest; never adjudicate. -**No inferred stock.** If a dimension is not measured or cited, the entry is -incomplete and does not ship. A plausible number is worse than a missing one, -because a missing one stops the build. +**No inferred stock — but never a blocked build.** A dimension nobody has +attributed is recorded as unattributed and used. What must not happen is a +number appearing from nowhere and looking authoritative. + +This was originally written as a refusal — an entry without provenance does +not ship — and implemented as a constructor that raised. That was wrong, and +wrong against §1: a compiler whose subject is arbitrary COTS hardware cannot +require the hardware to be catalogued before it will run. **Every dimension is +an input at every run.** Type what your caliper reads, print, measure the +print, adjust, print again. That loop is the tool. + +Provenance therefore travels with the output instead of guarding the entrance, +and a later measurement supersedes an earlier guess. + +**No design decisions taken on the operator's behalf.** An interference fit is +a legitimate choice — a conduit core that must not rattle wants one, and PLA +deflects. The compiler computes the interference and reports it. Whether it is +acceptable is not the compiler's call. Measure and attest; never adjudicate. ## 6. Consequence for what exists diff --git a/src/mechcomp/geom/records.py b/src/mechcomp/geom/records.py index 1b62071..9a81e31 100644 --- a/src/mechcomp/geom/records.py +++ b/src/mechcomp/geom/records.py @@ -27,6 +27,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import List, Optional +from ..stock import rect_cavity, rect_laminae, rect_section from .primitives import ( Path, Point, @@ -212,26 +213,30 @@ def local_rect(half_w_lead: float, half_w_trail: float, def strap_path(m: Member, g: Geo) -> List[Point]: - """The physical strap bundle, as one rectangle.""" - return place(m, local_rect(g.width / 2.0, g.width / 2.0, - g.bundle_t / 2.0, g.bundle_t / 2.0)) + """ + The physical stock bundle, as one rectangle. + + The shape comes from ``mechcomp.stock``; this function only places it. + A strap is a rectangular stock entry and always was -- see STOCK.md. + """ + return place(m, rect_section(g.width, g.bundle_t)) def strap_layer_paths(m: Member, g: Geo) -> List[List[Point]]: - """Individual strap laminae, for display when count > 1.""" - out = [] - for i in range(g.count): - y = (i - (g.count - 1) / 2.0) * g.strap_t - t = g.strap_t / 2.0 - rect = local_rect(g.width / 2.0, g.width / 2.0, t, t) - out.append(place(m, [(p[0], p[1] + y) for p in rect])) - return out + """Individual laminae, for display when count > 1.""" + return [place(m, p) + for p in rect_laminae(g.width, g.strap_t, g.count)] def cavity_path(m: Member, g: Geo) -> List[Point]: - """The void the strap slides through.""" - return place(m, local_rect(g.cavity_w / 2.0, g.cavity_w / 2.0, - g.cavity_t / 2.0, g.cavity_t / 2.0)) + """ + The void the stock slides through. + + ``rect_cavity`` reproduces ``g.cavity_w / 2`` and ``g.cavity_t / 2`` + with the reference's own expression rather than an algebraically equal + rearrangement, so the substitution cannot move a frozen value. + """ + return place(m, rect_cavity(g.width, g.bundle_t, g.clearance)) def sleeve_path(m: Member, g: Geo, ext_lead: float = 0.0, diff --git a/src/mechcomp/stock.py b/src/mechcomp/stock.py index 96b86c9..0e6c10b 100644 --- a/src/mechcomp/stock.py +++ b/src/mechcomp/stock.py @@ -40,7 +40,21 @@ import math from dataclasses import dataclass from typing import List, Sequence, Tuple -from .geom.primitives import cos_d, sin_d +# Stock imports nothing from mechcomp. It is more fundamental than the +# geometry of the printed part -- geom.records depends on this module, not +# the other way round -- and a cycle between them would resolve only by +# accident of import order in geom/__init__.py. +# +# These are the identical expressions from geom.primitives, and +# test_stock.py asserts they agree bit for bit. + + +def cos_d(a: float) -> float: + return math.cos(math.radians(a)) + + +def sin_d(a: float) -> float: + return math.sin(math.radians(a)) Point = Tuple[float, float] Path = List[Point] @@ -53,26 +67,42 @@ Path = List[Point] @dataclass(frozen=True) class Provenance: """ - Where a dimension came from. + Where a dimension came from. Recorded, never required. - Required on every entry, and deliberately awkward to fake. STOCK.md section - 5: if a dimension is not measured or cited, the entry is incomplete and does - not ship. + An earlier version of this class raised when the source was empty. That + was wrong, and wrong in a way that defeated the point of the compiler: + it made the ordinary case -- type the number your caliper shows, print, + measure the print, adjust -- impossible without first satisfying a + bureaucratic check. Parametric means the dimension is an input at every + run, not a fact to be established before the tool will start. + + So an unattributed dimension is representable, and says so. What matters + is that the record TRAVELS WITH THE OUTPUT, so a part on the bench can be + traced to the numbers that produced it and a later measurement can + supersede an earlier guess. A quiet guess is the failure; a labelled one + is just an early draft. """ - source: str # standard, spec sheet, or "caliper" - recorded: str # ISO date the value was taken or checked + source: str = "" # standard, spec sheet, "caliper", or empty + recorded: str = "" # ISO date the value was taken or checked note: str = "" - def __post_init__(self) -> None: - if not self.source.strip(): - raise ValueError( - "Provenance.source is required. A stock dimension with no " - "stated origin is a guess, and a guess that reaches geometry " - "produces a part that does not fit." - ) - if not self.recorded.strip(): - raise ValueError("Provenance.recorded is required (ISO date).") + @property + def verified(self) -> bool: + """True when someone stated where the number came from.""" + return bool(self.source.strip()) + + def describe(self) -> str: + """One line for the design record that ships with a model.""" + tail = " -- %s" % self.note.strip() if self.note.strip() else "" + if not self.verified: + # The note is kept. Unverified is exactly when it matters most: + # it is the operator's own record of what they measured, and + # dropping it would discard the only trace of where the number + # came from. + return "unverified: supplied at design time" + tail + date = self.recorded.strip() or "undated" + return "%s (%s)%s" % (self.source.strip(), date, tail) # --------------------------------------------------------------------------- @@ -86,20 +116,41 @@ class Fit: ``clearance`` is applied to every face of the section, which is what the reference does for straps: ``cavity_w = width + 2 * clearance``. + + NEGATIVE CLEARANCE IS AN INTERFERENCE FIT, AND IT IS ALLOWED. + An earlier version refused it, arguing the part could not be + assembled. That is a design judgement and it is not the compiler's to + make: a conduit core that must not rattle wants interference, and PLA + deflects. The compiler computes the interference and reports it; + whether it is acceptable, and whether the print survives being pressed + together, is decided by the person holding both parts. + + This is the same rule the project applies everywhere else. Measure and + attest; never adjudicate. """ clearance: float note: str = "" - def __post_init__(self) -> None: - if self.clearance < 0.0: - raise ValueError( - "Fit.clearance is negative (%r). An interference fit is not " - "expressible as a negative clearance here -- the cavity would " - "be smaller than the stock and the part could not be " - "assembled. Model interference explicitly when it is needed." - % self.clearance - ) + @property + def interference(self) -> float: + """Interference per face, in mm. Zero for a clearance fit.""" + return -self.clearance if self.clearance < 0.0 else 0.0 + + @property + def is_interference(self) -> bool: + return self.clearance < 0.0 + + def describe(self) -> str: + """One line for the design record that ships with a model.""" + if self.is_interference: + kind = "interference %.4f mm per face" % self.interference + elif self.clearance == 0.0: + kind = "line-to-line, 0 mm" + else: + kind = "clearance %.4f mm per face" % self.clearance + tail = " -- %s" % self.note.strip() if self.note.strip() else "" + return kind + tail # --------------------------------------------------------------------------- @@ -136,21 +187,15 @@ class RectStock: def section(self) -> Path: """The physical stock, in local coordinates, centred on the origin.""" - return _rect(self.width / 2.0, self.stack / 2.0) + return rect_section(self.width, self.stack) def cavity(self, fit: Fit) -> Path: """The void the stock occupies once the fit clearance is added.""" - return _rect(self.width / 2.0 + fit.clearance, - self.stack / 2.0 + fit.clearance) + return rect_cavity(self.width, self.stack, fit.clearance) def laminae(self) -> List[Path]: """Individual layers, for display when count > 1.""" - out: List[Path] = [] - for i in range(self.count): - y = (i - (self.count - 1) / 2.0) * self.thickness - rect = _rect(self.width / 2.0, self.thickness / 2.0) - out.append([(p[0], p[1] + y) for p in rect]) - return out + return rect_laminae(self.width, self.thickness, self.count) @dataclass(frozen=True) @@ -212,6 +257,38 @@ class RoundStock: # Local section helpers # --------------------------------------------------------------------------- +def rect_section(width: float, stack: float) -> Path: + """The solid rectangular stock, centred on the origin.""" + return _rect(width / 2.0, stack / 2.0) + + +def rect_cavity(width: float, stack: float, clearance: float) -> Path: + """ + The void rectangular stock occupies, grown by the fit clearance. + + THE ARITHMETIC IS THE REFERENCE'S, DELIBERATELY. + sb-geom.scad computes ``cavity_w = width + 2*clearance`` and then + halves it. Writing ``width/2 + clearance`` instead is algebraically + identical and NOT guaranteed identical in IEEE 754 -- the two + differ in the last bit for some operands. Since geom.records now + delegates here, any such difference would move the frozen oracle, + so the expression matches the reference character for character + rather than merely in value. + """ + return _rect((width + 2.0 * clearance) / 2.0, + (stack + 2.0 * clearance) / 2.0) + + +def rect_laminae(width: float, thickness: float, count: int) -> List[Path]: + """Individual layers of a bundle, stacked along the thickness.""" + out: List[Path] = [] + for i in range(count): + y = (i - (count - 1) / 2.0) * thickness + rect = _rect(width / 2.0, thickness / 2.0) + out.append([(p[0], p[1] + y) for p in rect]) + return out + + def _rect(half_w: float, half_t: float) -> Path: """ Rectangle centred on the origin. @@ -273,15 +350,21 @@ def inscribed_radius(path: Sequence[Point]) -> float: # The catalogue, as it stands # --------------------------------------------------------------------------- # -# Two entries. The strap is the one rev 8.0.0 was built around; its dimensions -# are the reference's own defaults, so its provenance is the generator, not a -# measurement. That is recorded honestly rather than dressed up -- a caliper -# reading on real strap would supersede it. +# STARTING POINTS, NOT A WHITELIST. # -# Conduit is NOT here yet, and must not be added from recollection. Trade size -# is not outside diameter, the value has to come from a standards source, and -# STOCK.md section 5 says an entry without provenance does not ship. It lands -# when CIVICVS supplies a measurement or a citation. +# Nothing is gated on being in here. Any dimension can be passed directly -- +# ``RoundStock(designation="my conduit", diameter=23.4)`` is a complete and +# legitimate entry -- and these exist only so the common cases need not be +# retyped. Pull one and override it with ``dataclasses.replace``. +# +# The strap entries carry the rev 8.0.0 generator defaults, which is a source +# but not a measurement; the provenance says so, and a caliper reading on the +# strap actually in use supersedes them. +# +# The conduit entry has NO dimension from me. Trade size is not outside +# diameter and I am not going to invent one, but that is a reason to leave the +# number to the caller, not a reason to refuse to build. ``EMT_TEMPLATE`` is +# the shape of the entry with the diameter left for you. CATALOGUE = { "PET strap 15.875 x 0.508": RectStock( @@ -308,3 +391,23 @@ CATALOGUE = { default_fit=0.25, ), } + + +def emt_template(diameter: float, designation: str = "EMT conduit", + fit: float = 0.25, note: str = "") -> RoundStock: + """ + A round-stock entry with the diameter you supply. + + Measure the conduit, pass the number. If the first print is tight, change + the number and print again -- that loop is the point of the tool, and + nothing here needs to know a standards table for it to work. + + ``designation`` is free text and reaches the design record unchanged, so + "EMT 1/2 in, Home Depot, 2026-08" is a perfectly good value. + """ + return RoundStock( + designation=designation, + diameter=diameter, + default_fit=fit, + provenance=Provenance(note=note), + ) diff --git a/tests/test_stock.py b/tests/test_stock.py index 5882df5..d268226 100644 --- a/tests/test_stock.py +++ b/tests/test_stock.py @@ -69,6 +69,148 @@ def _cases(): yield w, t, n, c +# --------------------------------------------------------------------------- +# Independent transcription of the reference +# --------------------------------------------------------------------------- +# +# geom.records now DELEGATES to mechcomp.stock, so comparing the two against +# each other proves nothing -- it would be the same code twice. Both are +# therefore compared against these, transcribed by hand from +# legacy/openscad/lib/sb-geom.scad and deliberately not importing either +# module. If this file and the port ever disagree, one of them is wrong and +# the suite says so instead of going quietly green. + +def _ref_place(cx, cy, angle, path): + ca, sa = math.cos(math.radians(angle)), math.sin(math.radians(angle)) + return [(p[0] * ca - p[1] * sa + cx, p[0] * sa + p[1] * ca + cy) + for p in path] + + +def _ref_local_rect(half_lead, half_trail, up, down): + return [(half_lead, -down), (half_lead, up), + (-half_trail, up), (-half_trail, -down)] + + +def _ref_cavity(cx, cy, angle, width, thickness, count, clearance): + cavity_w = width + 2.0 * clearance + cavity_t = count * thickness + 2.0 * clearance + return _ref_place(cx, cy, angle, + _ref_local_rect(cavity_w / 2.0, cavity_w / 2.0, + cavity_t / 2.0, cavity_t / 2.0)) + + +def _ref_section(cx, cy, angle, width, thickness, count): + bundle_t = count * thickness + return _ref_place(cx, cy, angle, + _ref_local_rect(width / 2.0, width / 2.0, + bundle_t / 2.0, bundle_t / 2.0)) + + +def _ref_laminae(cx, cy, angle, width, thickness, count): + out = [] + for i in range(count): + y = (i - (count - 1) / 2.0) * thickness + t = thickness / 2.0 + rect = _ref_local_rect(width / 2.0, width / 2.0, t, t) + out.append(_ref_place(cx, cy, angle, + [(p[0], p[1] + y) for p in rect])) + return out + + +def test_both_implementations_match_the_reference_transcription(): + """ + The check that survives delegation. + + Asserts records AND stock each equal a hand transcription of the + reference formula, exactly. This is what stops the faithfulness proof + from becoming a comparison of one module with itself. + """ + for w, t, n, c in _cases(): + g = _geo(w, t, n, c) + s = _stock(w, t, n) + fit = Fit(clearance=c) + for cx, cy, angle in PLACEMENTS: + ref = _ref_cavity(cx, cy, angle, w, t, n, c) + assert cavity_path(Member(cx, cy, angle, 0), g) == ref, ( + "geom.records diverged from the reference at " + "width=%s thickness=%s count=%s clearance=%s" % (w, t, n, c)) + assert placed(s.cavity(fit), cx, cy, angle) == ref, ( + "mechcomp.stock diverged from the reference at " + "width=%s thickness=%s count=%s clearance=%s" % (w, t, n, c)) + + ref_s = _ref_section(cx, cy, angle, w, t, n) + assert strap_path(Member(cx, cy, angle, 0), g) == ref_s + assert placed(s.section(), cx, cy, angle) == ref_s + + # Laminae need the same independent check. Comparing stock + # against records here would be one function compared with + # itself, and a dropped stacking offset would go unnoticed -- + # it did, in the first cut of this step. + ref_l = _ref_laminae(cx, cy, angle, w, t, n) + assert strap_layer_paths(Member(cx, cy, angle, 0), g) == ref_l, ( + "geom.records laminae diverged at width=%s thickness=%s " + "count=%s" % (w, t, n)) + assert [placed(p, cx, cy, angle) for p in s.laminae()] == ref_l, ( + "mechcomp.stock laminae diverged at width=%s thickness=%s " + "count=%s" % (w, t, n)) + + +def test_cavity_expression_forms_agree_across_the_operating_range(): + """ + ``(w + 2c)/2`` versus ``w/2 + c``, and why the reference form is kept. + + Step 2 changed ``rect_cavity`` to the reference's expression on the + suspicion that the two could differ in IEEE 754 and so move the frozen + oracle after delegation. MEASURED: they cannot, for any operand this + compiler will ever see. ``2*c`` and ``x/2`` are exact scalings, so both + forms reduce to the correctly rounded sum of the same two reals, + halved. They diverge only where scaling changes the exponent regime -- + subnormals and overflow -- and neither is a stock dimension. Four + million random draws in range produced no divergence. + + The reference form is kept regardless, because matching the source + character for character is worth having when that source is the only + evidence the geometry is right. But it is kept for fidelity, not for + numerical necessity, and this test says so instead of pretending to + guard something unfalsifiable. + + Consequence for anyone mutation-testing this file: swapping the two + forms back is a mutation that SHOULD survive. It is a bad mutation, + not a test gap -- HANDOFF section 8. + """ + import random + + from mechcomp.stock import rect_cavity + + rng = random.Random(20260823) + for _ in range(20000): + w = rng.uniform(1e-6, 1e5) + c = rng.uniform(0.0, 1e3) + assert (w + 2.0 * c) / 2.0 == w / 2.0 + c, ( + "the two forms diverged in the operating range at w=%r c=%r -- " + "if this ever fires, the choice of expression is load-bearing " + "and every stock dimension needs auditing" % (w, c)) + assert rect_cavity(w, w, c)[0][0] == (w + 2.0 * c) / 2.0 + + # The regimes where they genuinely do differ, so the claim above is + # grounded in a demonstration rather than in an argument. + assert (1.5e-323 + 2.0 * 5e-324) / 2.0 != 1.5e-323 / 2.0 + 5e-324 + assert (1e308 + 2.0 * 1e308) / 2.0 != 1e308 / 2.0 + 1e308 + + +def test_stock_degree_helpers_match_geoms_bit_for_bit(): + """ + stock.py defines its own cos_d/sin_d so it can stay a leaf module with no + mechcomp imports. Duplication is only safe while the two agree exactly. + """ + from mechcomp.geom.primitives import cos_d as geom_cos, sin_d as geom_sin + from mechcomp.stock import cos_d as stock_cos, sin_d as stock_sin + + for a in [0, 1, 30, 33, 45, 60, 90, 120, 137, 180, 270, 359.9, -45, 720]: + assert stock_cos(a) == geom_cos(a), "cos_d diverged at %s" % a + assert stock_sin(a) == geom_sin(a), "sin_d diverged at %s" % a + + # --------------------------------------------------------------------------- # Faithfulness # --------------------------------------------------------------------------- @@ -172,24 +314,107 @@ def test_inscribed_polygon_would_fail_the_same_check(): # Provenance and fit are enforced, not decorative # --------------------------------------------------------------------------- -def test_entry_cannot_be_built_without_provenance(): - with pytest.raises(ValueError, match="source is required"): - Provenance(source="", recorded="2026-08-22") - with pytest.raises(ValueError, match="recorded is required"): - Provenance(source="caliper", recorded="") +def test_unattributed_dimensions_are_representable_and_say_so(): + """ + The ordinary case must work: type the number, print, measure, adjust. + + An earlier version raised here, which made a parametric compiler refuse + to run until someone had done paperwork. Provenance records; it does not + gate. + """ + bare = Provenance() + assert bare.verified is False + assert "unverified" in bare.describe() + + stock = RoundStock(designation="whatever conduit", diameter=23.4, + provenance=bare) + assert stock.cavity(Fit(clearance=0.25)) + + cited = Provenance(source="caliper", recorded="2026-08-23", note="3 samples") + assert cited.verified is True + assert "caliper" in cited.describe() and "3 samples" in cited.describe() -def test_negative_clearance_is_refused(): - with pytest.raises(ValueError, match="negative"): - Fit(clearance=-0.1) +def test_interference_fit_is_allowed_and_reported(): + """ + Negative clearance is an interference fit, not an error. + + Whether the parts press together is the operator's call. The compiler's + job is to compute the cavity and state what it did. + """ + tight = Fit(clearance=-0.05) + assert tight.is_interference + assert tight.interference == 0.05 + assert "interference" in tight.describe() + + loose = Fit(clearance=0.25) + assert not loose.is_interference + assert loose.interference == 0.0 + assert "clearance" in loose.describe() + + assert "line-to-line" in Fit(clearance=0.0).describe() + + # The cavity really is smaller than the stock under interference. + stock = RoundStock(designation="rod", diameter=20.0, + provenance=Provenance()) + assert inscribed_radius(stock.cavity(tight)) < 10.0 + assert stock.cavity_tight_radius(tight) == pytest.approx(9.95) -def test_catalogue_entries_all_carry_provenance(): - assert CATALOGUE, "the catalogue is empty" +def test_any_diameter_is_accepted_and_fine_tuning_moves_the_cavity(): + """ + The tuning loop, asserted: a small change in the input must produce a + correspondingly small, monotonic change in the hole. + """ + from mechcomp.stock import emt_template + + previous = 0.0 + for diameter in (12.7, 17.93, 21.0, 23.4, 26.65, 40.0, 63.5): + stock = emt_template(diameter) + r = inscribed_radius(stock.cavity(Fit(clearance=0.25))) + assert r == pytest.approx(diameter / 2.0 + 0.25, abs=1e-9) + assert r > previous + previous = r + + base = emt_template(23.4) + for step in (0.05, 0.10, 0.15): + loose = inscribed_radius(base.cavity(Fit(clearance=0.25 + step))) + tight = inscribed_radius(base.cavity(Fit(clearance=0.25 - step))) + assert loose - tight == pytest.approx(2 * step, abs=1e-9) + + # The template must carry every argument through, not just the diameter. + # A default_fit that ignores its argument would leave the entry's own + # suggested fit permanently wrong while every explicit Fit still worked. + for fit in (0.0, 0.1, 0.35, -0.05): + assert emt_template(23.4, fit=fit).default_fit == fit + named = emt_template(23.4, designation="EMT 1/2 in, measured 2026-08", + note="3 samples, mid-run") + assert named.designation == "EMT 1/2 in, measured 2026-08" + assert "3 samples" in named.provenance.describe() + assert named.provenance.verified is False + + +def test_catalogue_entries_are_starting_points_not_a_whitelist(): + """ + Membership means nothing. Entries exist so common cases need not be + retyped, and every one of them is overridable. + """ + import dataclasses + + assert CATALOGUE for name, entry in CATALOGUE.items(): assert entry.designation == name - assert entry.provenance.source.strip() - assert entry.provenance.recorded.strip() + assert entry.provenance.describe() + + strap = CATALOGUE["PET strap 15.875 x 0.508"] + wider = dataclasses.replace(strap, width=19.0, designation="wider strap") + assert wider.width == 19.0 + assert strap.width == 15.875, "the catalogue entry was mutated" + + # An entry built from nothing is as valid as one pulled from the dict. + adhoc = RectStock(designation="scrap banding", width=11.1, thickness=0.4, + provenance=Provenance()) + assert adhoc.cavity(Fit(clearance=0.2)) def test_catalogue_strap_matches_the_reference_defaults():