diff --git a/src/mechcomp/design_record.py b/src/mechcomp/design_record.py index db303bf..184df1f 100644 --- a/src/mechcomp/design_record.py +++ b/src/mechcomp/design_record.py @@ -41,6 +41,23 @@ TWO IDENTITIES, BECAUSE THEY ANSWER DIFFERENT QUESTIONS 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. + +AUTHORSHIP IS A THIRD KIND, AND IT IS IN NEITHER HASH + Authorship is not an input and not an output. It does not regenerate a + model and it does not confirm one; it says who asked for it. + + It is excluded from both ids deliberately. ``input_id`` answers "is this + the same part as specified", so two people specifying the same part must + collide there -- hashing the author would make identical parts claim to be + different designs, which is the opposite of what the id is for. + + One consequence worth stating, because it bounds how much that exclusion + buys: a record regenerated later with the author filled in keeps the same + ``input_id``, but ``build_id`` covers the code revision and the Shapely and + GEOS versions, and boolean results on near-degenerate geometry can shift + between GEOS releases. Regenerate after a GEOS bump and you get attribution + under a different build identity beside a part nobody can now tie to + either. Attribution is recoverable, not free. """ from __future__ import annotations @@ -73,6 +90,67 @@ VERIFY_KEYS: Tuple[str, ...] = ( ) +# --------------------------------------------------------------------------- +# Authorship +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Author: + """ + Who asked for a model. The email address is the identity. + + WHY A SEPARATE TYPE RATHER THAN ``stock.Provenance`` + Provenance answers "where did this dimension come from" and its fields + -- source, recorded, note -- are about measurement. Authorship answers + "who claims to have made this", which is a different question with a + different failure mode: a wrong dimension is caught by a caliper, a + wrong identity is caught by nothing. The discipline is borrowed; the + type is not. This module also imports nothing from ``mechcomp``, which + is what keeps a failure in the record off the build path. + + WHY THE RENDERED LINE CARRIES ITS OWN LIMIT + An unverified email in a field called ``author`` reads as identity to + anyone who finds the file in five years, and the record is designed to + outlive everyone present. So the line says what it knows: + + author theron@kane-il.us (self-declared, unverified) + + Nothing here checks that the address belongs to the person who typed + it, and nothing should pretend otherwise. When an authenticated + identity exists, ``method`` names the mechanism and the qualifier + changes -- the distinction stays legible either way. + + A HANDLE IS NOT THE IDENTITY + A person may later choose a handle. It is a display name and it does + not replace the address: ``handle`` is decoration, ``email`` is the id. + """ + + email: str = "" + handle: str = "" + verified: bool = False + method: str = "" # how it was verified, when it was + + @property + def present(self) -> bool: + return bool(self.email.strip()) + + def qualifier(self) -> str: + """The limit on the claim, in the record's own words.""" + if not self.verified: + return "self-declared, unverified" + how = self.method.strip() + return "verified: %s" % how if how else "verified" + + def describe(self) -> str: + """One line for the design record that ships with a model.""" + if not self.present: + return "" + name = self.email.strip() + if self.handle.strip(): + name = "%s (%s)" % (name, self.handle.strip()) + return "%s (%s)" % (name, self.qualifier()) + + # --------------------------------------------------------------------------- # Canonical form # --------------------------------------------------------------------------- @@ -151,6 +229,11 @@ class DesignRecord: 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. + + ``author`` is excluded for a different reason -- see the module docstring. + The two exclusions are not the same rule and should not be collapsed: a + timestamp is noise, an author is a fact about a person that simply is not + part of what the part IS. """ family: str @@ -167,6 +250,7 @@ class DesignRecord: verification: Dict[str, object] = field(default_factory=dict) generated: str = "" note: str = "" + author: Optional[Author] = None # -- identity ---------------------------------------------------------- @@ -222,6 +306,8 @@ class DesignRecord: "family %s" % self.family, "profile %s" % self.profile, ] + if self.author is not None and self.author.present: + lines.append("author %s" % self.author.describe()) if self.generated: lines.append("generated %s (excluded from both ids)" % self.generated) if self.note: @@ -264,11 +350,24 @@ class DesignRecord: 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. + + AUTHORSHIP GETS ITS OWN BRANCH, ON PURPOSE. + It is neither input nor output, so it inherits neither rule. It is + recovered, because dropping the author when a record is read and + rewritten would silently strip attribution -- but it is recovered + UNVERIFIED, whatever the text claimed. + + That is deliberate and it is the only safe direction. A text file + cannot attest to its own verification: anyone can type + "(verified: ...)" into one. Reading a verified record back as + self-declared understates the claim; doing the reverse would let a + forged line launder itself into a trusted one. """ family = profile = "" code_revision = generator_revision = "unknown" params: Dict[str, object] = {} toolchain: Dict[str, str] = {} + author: Optional[Author] = None section = "" for raw in text.splitlines(): @@ -298,12 +397,21 @@ class DesignRecord: family = line.split(None, 1)[1].strip() elif line.startswith("profile "): profile = line.split(None, 1)[1].strip() + elif line.startswith("author "): + # The address is the first token after the label and can hold + # no whitespace, so everything beyond it is the qualifier and + # any handle -- read for the reader's benefit, not trusted. + rest = line.split(None, 1)[1].strip() + email = rest.split(None, 1)[0] if rest else "" + if email: + author = Author(email=email) return DesignRecord( family=family, profile=profile, params=params, code_revision=code_revision, generator_revision=generator_revision, toolchain=toolchain, + author=author, ) @@ -384,6 +492,23 @@ def toolchain_versions() -> Dict[str, str]: return dict(out) +def author_from(value: object) -> Optional[Author]: + """ + Accept what a caller has: an ``Author``, a bare address, or nothing. + + A bare string is the ordinary case -- the composer has one text field -- + and it becomes an unverified self-declaration, because that is all a typed + address is. Empty and whitespace are ``None`` rather than an empty author, + so an absent author renders as nothing at all instead of an empty claim. + """ + if value is None: + return None + if isinstance(value, Author): + return value if value.present else None + text = str(value).strip() + return Author(email=text) if text else None + + def record_for(family: str, profile: str, defaults: Mapping[str, object], params: Optional[Mapping[str, object]] = None, @@ -393,12 +518,17 @@ def record_for(family: str, profile: str, code_revision: str = "unknown", generator_revision: str = "unknown", generated: str = "", - note: str = "") -> DesignRecord: + note: str = "", + author: object = None) -> 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. + + ``author`` never touches ``params``, and that placement is the whole + guarantee: the hashes are computed from the resolved parameter set, so an + author that is not in it cannot reach either id. """ verification: Dict[str, object] = {} if report: @@ -416,4 +546,5 @@ def record_for(family: str, profile: str, verification=verification, generated=generated, note=note, + author=author_from(author), ) diff --git a/src/mechcomp/profiles/__init__.py b/src/mechcomp/profiles/__init__.py index dd1a8bf..366f5f0 100644 --- a/src/mechcomp/profiles/__init__.py +++ b/src/mechcomp/profiles/__init__.py @@ -12,6 +12,12 @@ Parameter names are the OpenSCAD ones, unchanged. ``params`` carries only the overrides for a case; everything else comes from the family's declared defaults, which is why those defaults are part of the port rather than part of the test harness. + +``author`` is a separate argument rather than a parameter, and that is the +point. Parameters are hashed into ``input_id``; an author is not part of what a +part IS, so it travels beside the parameter set and never inside it. Callers +that do not supply one -- the oracle harness among them -- get exactly the +behaviour they had before. """ from __future__ import annotations @@ -32,7 +38,8 @@ FAMILIES: Dict[str, Family] = { def build(family: str, profile: str, - params: Optional[Mapping[str, object]] = None) -> Result: + params: Optional[Mapping[str, object]] = None, + author: object = None) -> Result: """ Build one cross-section and return its report. @@ -41,6 +48,9 @@ def build(family: str, profile: str, does. An unknown *family* is a caller error rather than a rejection, so it raises ``ValueError``: silently rejecting it would make a missing generator indistinguishable from a case the reference declined. + + ``author`` accepts an email address, a ``design_record.Author``, or nothing. + It reaches the design record and nothing else. """ fam = FAMILIES.get(family) if fam is None: @@ -48,4 +58,4 @@ def build(family: str, profile: str, "unknown family %r; this port implements %s" % (family, ", ".join(sorted(FAMILIES))) ) - return assemble(fam, profile, params or {}) + return assemble(fam, profile, params or {}, author) diff --git a/src/mechcomp/profiles/_common.py b/src/mechcomp/profiles/_common.py index df8b669..6a11666 100644 --- a/src/mechcomp/profiles/_common.py +++ b/src/mechcomp/profiles/_common.py @@ -39,6 +39,11 @@ from mechcomp.geom import ( # Generator revision. Qualification attestations bind to this, so any change # that alters emitted geometry must bump it. See geom/report.py for what is # published. +# +# Authorship did not bump it. The field is recorded beside the model and +# reaches no geometry, so a part built before it existed and the same part +# built after are the same bytes -- which is exactly what an unbumped +# generator revision asserts. REVISION = "8.0.0" Params = Mapping[str, object] @@ -57,7 +62,7 @@ class Family: def _design_record(family: Family, profile_type: str, params: Params, - geo: Geo, rep: Dict[str, object]): + geo: Geo, rep: Dict[str, object], author: object = None): """ The record that ships with this model. @@ -67,6 +72,11 @@ def _design_record(family: Family, profile_type: str, params: Params, was built rather than what was asked for -- the two agree today and the record should keep saying so if they ever stop. + ``author`` arrives from the caller and is passed straight through. It is + deliberately NOT merged into ``params``: the ids are computed from the + resolved parameter set, so keeping it out of that mapping is what keeps it + out of both hashes. + 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. @@ -95,6 +105,7 @@ def _design_record(family: Family, profile_type: str, params: Params, fit=[fit.describe()], code_revision=code_revision(), generator_revision=REVISION, + author=author, ) @@ -138,7 +149,8 @@ def rect_outline(w: float, h: float) -> List[tuple]: # The build # --------------------------------------------------------------------------- -def assemble(family: Family, profile_type: str, params: Params) -> Result: +def assemble(family: Family, profile_type: str, params: Params, + author: object = None) -> Result: p = {**family.defaults, **dict(params or {})} geo = make_geo(p) @@ -170,7 +182,8 @@ def assemble(family: Family, profile_type: str, params: Params) -> Result: ) return Result(report=rep, section=c.section, members=c.members, geo=geo, - record=_design_record(family, profile_type, params, geo, rep)) + record=_design_record(family, profile_type, params, geo, rep, + author)) def geometry_checks(p: Params) -> List[Check]: diff --git a/src/mechcomp/web/app.py b/src/mechcomp/web/app.py index 5046864..744e491 100644 --- a/src/mechcomp/web/app.py +++ b/src/mechcomp/web/app.py @@ -14,6 +14,12 @@ WHAT IT SHOWS what you are looking at is visible while you tune, not discovered afterwards. + The author field is part of that. It is an email address, it is never + checked, and the record says so on the line it appears on. Nothing here + persists it: it lives in the page for as long as the page is open, because + remembering it across visits would be the first half of a persistence + layer that does not exist yet. + WHAT IT DOES NOT DO It does not export STL, and it does not persist anything. It is a viewer and a control panel over ``build()``. Rejections are shown as messages rather @@ -95,7 +101,8 @@ def profile_params(family, profile: str) -> List[Tuple[str, List[str]]]: def build_payload(family_name: str, profile: str, - overrides: Dict[str, Any]) -> Dict[str, Any]: + overrides: Dict[str, Any], + author: str = "") -> Dict[str, Any]: from mechcomp.profiles import ProfileRejected, build family = families()[family_name] @@ -126,7 +133,8 @@ def build_payload(family_name: str, profile: str, } try: - result = build(family=family_name, profile=profile, params=typed) + result = build(family=family_name, profile=profile, params=typed, + author=author) except ProfileRejected as exc: payload["ok"] = False payload["message"] = str(exc) @@ -170,6 +178,8 @@ PAGE = """ input,select { font:inherit; font-size:12.5px; padding:4px 7px; border:1px solid var(--line); border-radius:5px; background:#fff; width:100%; } input:focus,select:focus { outline:2px solid var(--accent); outline-offset:-1px; } + label.wide { grid-template-columns:1fr; gap:3px; } + .hint { font-size:11px; color:#7b8794; margin:-2px 0 10px; } .figure { background:#fff; border:1px solid var(--line); border-radius:9px; padding:18px; display:inline-block; min-width:300px; min-height:200px; } .bad { background:#fff4ed; border:1px solid #f0c3a2; border-radius:9px; @@ -198,6 +208,13 @@ PAGE = """ +