stock: records delegates to the descriptor, and the descriptor stops gating

geom.records no longer computes the section, cavity or laminae. It calls mechcomp.stock, so the shape of a piece of stock is defined once. All 123 oracle cases unmoved: 482 passed. stock.py also becomes a leaf module, ending an import cycle with geom that resolved only by accident of ordering.

Three defects in f5651d3 corrected. Provenance raised on an empty source, which made an unattributed dimension unrepresentable and blocked the ordinary use of the tool: type what the caliper reads, print, measure the print, adjust. Provenance now records and travels with the output. Fit refused negative clearance on the argument that interference is not assemblable, which is a design judgement and not the compilers to make. Interference is now computed and reported. CATALOGUE read as a whitelist and is documented as starting points, with a test asserting an entry built from nothing is as valid as one pulled from the dict.

emt_template takes the diameter, fit, designation and note from the caller. There is no standards table and no lookup. A parametric compiler cannot require its subject to be catalogued before it will run.

STOCK.md section 5 amended, since the refusals were implementing it. An entry without provenance no longer fails to ship, it ships labelled unattributed. The principle that a number must not appear from nowhere looking authoritative survives; the door does not.

Tests compare records and stock against a hand transcription of the reference rather than against each other, which would be tautological after delegation. Mutation testing found four gaps before landing: a dropped lamina stacking offset, emt_template silently ignoring its fit argument, describe discarding the note exactly when provenance was unverified, and a guard on float arithmetic that asserted a tautology.
This commit is contained in:
2026-08-23 10:24:39 -05:00
parent f5651d3a62
commit 70787ea17d
4 changed files with 419 additions and 71 deletions
+19 -14
View File
@@ -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,
+145 -42
View File
@@ -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),
)