gitcad-core 0.7.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ """Dependency resolution and the lockfile (ADR-0009).
2
+
3
+ A dependency is an intent constraint (`^1.4`); the lock pins the exact resolved
4
+ version *and its canonical-text content hash*, so a build is byte-reproducible
5
+ from a git tag forever. Resolution is deliberately simple in v1: highest
6
+ available version satisfying the constraint, from a local Workspace (a
7
+ directory of part.json files). The registry (ADR-0010) slots in behind the
8
+ same Workspace shape later.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from gitcad.canonical import canonical_json
18
+ from gitcad.errors import GitcadError
19
+ from gitcad.part.manifest import PartManifest
20
+ from gitcad.part.semver import Version, satisfies
21
+
22
+
23
+ class Workspace:
24
+ """All known part versions, keyed by part id. v1 source: a directory tree
25
+ scanned for ``*.part.json`` / ``part.json`` files."""
26
+
27
+ def __init__(self) -> None:
28
+ self._versions: dict[str, dict[str, PartManifest]] = {}
29
+
30
+ def add(self, manifest: PartManifest) -> None:
31
+ self._versions.setdefault(manifest.id, {})[manifest.version] = manifest
32
+
33
+ @classmethod
34
+ def scan(cls, root: str) -> "Workspace":
35
+ ws = cls()
36
+ for path in sorted(Path(root).rglob("*part.json")):
37
+ ws.add(PartManifest.loads(path.read_text()))
38
+ return ws
39
+
40
+ @classmethod
41
+ def from_git(cls, url: str, *, ref: str = "main", cache_dir: str | None = None) -> "Workspace":
42
+ """A workspace backed by a git registry (e.g. gitcad-xyz/registry).
43
+
44
+ Shallow-clones (or updates) into a local cache and scans it — the
45
+ registry IS a git repo (ADR-0010), so the client is just git + scan.
46
+ Resolution then pins content hashes (ADR-0009) exactly as with any
47
+ local workspace.
48
+ """
49
+ import hashlib
50
+ import subprocess
51
+ import tempfile
52
+
53
+ key = hashlib.blake2b(f"{url}#{ref}".encode(), digest_size=8).hexdigest()
54
+ dest = Path(cache_dir) if cache_dir else Path(tempfile.gettempdir()) / "gitcad-registry" / key
55
+ if (dest / ".git").exists():
56
+ subprocess.run(["git", "-C", str(dest), "fetch", "--depth", "1", "origin", ref],
57
+ check=True, capture_output=True)
58
+ subprocess.run(["git", "-C", str(dest), "checkout", "-q", "FETCH_HEAD"],
59
+ check=True, capture_output=True)
60
+ else:
61
+ dest.parent.mkdir(parents=True, exist_ok=True)
62
+ subprocess.run(["git", "clone", "--depth", "1", "--branch", ref, url, str(dest)],
63
+ check=True, capture_output=True)
64
+ return cls.scan(str(dest))
65
+
66
+ def versions_of(self, part_id: str) -> list[str]:
67
+ return sorted(self._versions.get(part_id, {}), key=Version.parse)
68
+
69
+ def get(self, part_id: str, version: str) -> PartManifest:
70
+ try:
71
+ return self._versions[part_id][version]
72
+ except KeyError:
73
+ raise GitcadError(f"workspace has no {part_id}@{version}") from None
74
+
75
+
76
+ @dataclass
77
+ class Lockfile:
78
+ """{part id: {version, content}} — committed next to the consumer."""
79
+
80
+ locks: dict[str, dict[str, str]] = field(default_factory=dict)
81
+
82
+ SCHEMA = "gitcad/lock@1"
83
+
84
+ def dumps(self) -> str:
85
+ return canonical_json({"schema": self.SCHEMA, "locks": self.locks}, indent=2) + "\n"
86
+
87
+ @classmethod
88
+ def loads(cls, text: str) -> "Lockfile":
89
+ doc = json.loads(text)
90
+ if doc.get("schema") != cls.SCHEMA:
91
+ raise GitcadError(f"unsupported lock schema {doc.get('schema')!r}")
92
+ return cls(locks=dict(doc["locks"]))
93
+
94
+ def verify(self, workspace: Workspace) -> list[str]:
95
+ """Confirm every locked hash still matches the workspace content —
96
+ catches tampering and accidental drift. Empty list = sound."""
97
+ problems: list[str] = []
98
+ for part_id, entry in sorted(self.locks.items()):
99
+ try:
100
+ manifest = workspace.get(part_id, entry["version"])
101
+ except GitcadError:
102
+ problems.append(f"missing:{part_id}@{entry['version']}")
103
+ continue
104
+ actual = manifest.content_hash()
105
+ if actual != entry["content"]:
106
+ problems.append(f"hash-mismatch:{part_id}@{entry['version']}")
107
+ return problems
108
+
109
+
110
+ def resolve(consumer: PartManifest, workspace: Workspace) -> Lockfile:
111
+ """Resolve ``consumer.deps`` (and transitively their deps) against the
112
+ workspace: highest version satisfying each constraint. Deterministic —
113
+ same workspace + same manifest → identical lockfile."""
114
+ lock = Lockfile()
115
+ queue = list(consumer.deps.items())
116
+ while queue:
117
+ part_id, constraint = queue.pop(0)
118
+ candidates = [v for v in workspace.versions_of(part_id) if satisfies(v, constraint)]
119
+ if not candidates:
120
+ raise GitcadError(
121
+ f"unresolvable: {part_id} {constraint!r} "
122
+ f"(available: {workspace.versions_of(part_id) or 'none'})"
123
+ )
124
+ chosen = candidates[-1] # versions_of is sorted ascending
125
+ prior = lock.locks.get(part_id)
126
+ if prior is not None:
127
+ if prior["version"] != chosen and not satisfies(prior["version"], constraint):
128
+ raise GitcadError(f"conflict: {part_id} locked {prior['version']}, needs {constraint}")
129
+ continue
130
+ manifest = workspace.get(part_id, chosen)
131
+ lock.locks[part_id] = {"version": chosen, "content": manifest.content_hash()}
132
+ queue.extend(manifest.deps.items())
133
+ return lock
@@ -0,0 +1,77 @@
1
+ """The part manifest — ``part.json`` (ADR-0008).
2
+
3
+ Domain-neutral wrapper every part carries: identity, version, interface, deps,
4
+ and an opaque domain-specific body core never parses. Canonical serialization
5
+ and content hashing follow the same rules as the document model (ADR-0004):
6
+ byte-stable text is what makes locks, diffs, and releases trustworthy.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import secrets
14
+ from dataclasses import dataclass, field
15
+
16
+ from gitcad.canonical import canonical_json
17
+ from gitcad.errors import GitcadError
18
+ from gitcad.part.interface import Interface
19
+ from gitcad.part.semver import Version
20
+
21
+ KNOWN_DOMAINS = {"mech", "ecad", "ecad.component", "assembly"} # open set; these get validation
22
+
23
+
24
+ def new_part_id() -> str:
25
+ """Mint a part id. Assigned once at creation and stable forever — never
26
+ derived from name or path (ADR-0003 discipline, one level up)."""
27
+ return "prt_" + secrets.token_hex(8)
28
+
29
+
30
+ @dataclass
31
+ class PartManifest:
32
+ id: str
33
+ name: str
34
+ domain: str
35
+ version: str
36
+ interface: Interface = field(default_factory=Interface)
37
+ deps: dict[str, str] = field(default_factory=dict) # part id -> constraint
38
+ body: dict = field(default_factory=dict)
39
+
40
+ SCHEMA = "gitcad/part@1"
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.id.startswith("prt_"):
44
+ raise GitcadError(f"part id must start with 'prt_': {self.id!r}")
45
+ Version.parse(self.version) # validates
46
+
47
+ def dumps(self) -> str:
48
+ doc = {
49
+ "schema": self.SCHEMA,
50
+ "id": self.id,
51
+ "name": self.name,
52
+ "domain": self.domain,
53
+ "version": self.version,
54
+ "interface": self.interface.to_dict(),
55
+ "deps": dict(sorted(self.deps.items())),
56
+ "body": self.body,
57
+ }
58
+ return canonical_json(doc, indent=2) + "\n"
59
+
60
+ @classmethod
61
+ def loads(cls, text: str) -> "PartManifest":
62
+ doc = json.loads(text)
63
+ if doc.get("schema") != cls.SCHEMA:
64
+ raise GitcadError(f"unsupported part schema {doc.get('schema')!r}")
65
+ return cls(
66
+ id=doc["id"],
67
+ name=doc["name"],
68
+ domain=doc["domain"],
69
+ version=doc["version"],
70
+ interface=Interface.from_dict(doc["interface"]),
71
+ deps=dict(doc.get("deps", {})),
72
+ body=dict(doc.get("body", {})),
73
+ )
74
+
75
+ def content_hash(self) -> str:
76
+ """Hash of the canonical text — what the lockfile pins (ADR-0009)."""
77
+ return "blake2b:" + hashlib.blake2b(self.dumps().encode(), digest_size=16).hexdigest()
@@ -0,0 +1,88 @@
1
+ """mate_solve — place instances by mate intent (ADR-0014, authoring-time).
2
+
3
+ The build never solves; it only checks. This tool runs at authoring time,
4
+ computes the rigid translation that makes each mated port pair coincide,
5
+ and writes the solved transforms back into the assembly — which then
6
+ validates like any hand-placed assembly. Rotation is respected but not
7
+ solved (v1: rotate_z_deg stays what the author set; the solve moves, it
8
+ does not spin — spinning has branch multiplicity, translation does not).
9
+
10
+ Traversal is BFS from a base instance (the most-mated, ties by name — the
11
+ same deterministic choice auto_explode makes). A mate between two
12
+ already-placed instances becomes a CHECK: if their ports don't coincide,
13
+ that's an over-constraint conflict, reported with the gap, never "fixed"
14
+ by silently moving something the solver already placed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass, field
21
+
22
+ from gitcad.part.assembly import Assembly
23
+
24
+
25
+ @dataclass
26
+ class MateSolveReport:
27
+ base: str = ""
28
+ solved: list[str] = field(default_factory=list) # instances moved
29
+ unreachable: list[str] = field(default_factory=list) # no mate path to base
30
+ conflicts: list[str] = field(default_factory=list) # "a.p<->b.q:gap=..mm"
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ return not self.conflicts
35
+
36
+ def to_dict(self) -> dict:
37
+ return {"base": self.base, "solved": self.solved,
38
+ "unreachable": self.unreachable,
39
+ "conflicts": self.conflicts, "ok": self.ok}
40
+
41
+
42
+ def mate_solve(asm: Assembly, *, base: str | None = None,
43
+ tol: float = 1e-6) -> MateSolveReport:
44
+ """Solve instance translations so mated ports coincide. Mutates ``asm``
45
+ (authoring writes back into reviewable text); returns the report."""
46
+ report = MateSolveReport()
47
+ if not asm.instances:
48
+ return report
49
+
50
+ adj: dict[str, list[tuple[str, str, str]]] = {n: [] for n in asm.instances}
51
+ for mate in asm.mates:
52
+ (ia, pa), (ib, pb) = mate.split()
53
+ adj[ia].append((ib, pa, pb))
54
+ adj[ib].append((ia, pb, pa))
55
+
56
+ if base is None:
57
+ base = max(adj, key=lambda n: (len(adj[n]), n))
58
+ report.base = base
59
+
60
+ placed = {base}
61
+ queue = [base]
62
+ while queue:
63
+ cur = queue.pop(0)
64
+ for other, cur_port, other_port in sorted(adj[cur]):
65
+ anchor = asm.instances[cur].port_position(cur_port)
66
+ inst = asm.instances[other]
67
+ if other in placed:
68
+ # over-constraint: verify instead of move
69
+ have = inst.port_position(other_port)
70
+ gap = math.dist(anchor, have)
71
+ if gap > tol:
72
+ a, b = sorted([f"{cur}.{cur_port}", f"{other}.{other_port}"])
73
+ entry = f"{a}<->{b}:gap={gap:.6g}mm"
74
+ if entry not in report.conflicts:
75
+ report.conflicts.append(entry)
76
+ continue
77
+ # port position with zero translate = local (rotated) port offset
78
+ local = tuple(p - t for p, t in
79
+ zip(inst.port_position(other_port), inst.translate))
80
+ inst.translate = tuple(round(a - l, 9) for a, l in zip(anchor, local))
81
+ placed.add(other)
82
+ report.solved.append(other)
83
+ queue.append(other)
84
+
85
+ report.unreachable = sorted(set(asm.instances) - placed)
86
+ report.solved.sort()
87
+ report.conflicts.sort()
88
+ return report
gitcad/part/semver.py ADDED
@@ -0,0 +1,81 @@
1
+ """Semantic versions and dependency constraints (ADR-0009).
2
+
3
+ Small on purpose: exactly the subset the lockfile needs — `^` (compatible),
4
+ `~` (patch-level), exact, and `*`. No pre-release/build tags in v1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from functools import total_ordering
12
+
13
+ from gitcad.errors import GitcadError
14
+
15
+ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
16
+
17
+ BUMPS = ("patch", "minor", "major")
18
+
19
+
20
+ @total_ordering
21
+ @dataclass(frozen=True)
22
+ class Version:
23
+ major: int
24
+ minor: int
25
+ patch: int
26
+
27
+ @classmethod
28
+ def parse(cls, text: str) -> "Version":
29
+ m = _VERSION_RE.match(text.strip())
30
+ if not m:
31
+ raise GitcadError(f"invalid version {text!r} (want MAJOR.MINOR.PATCH)")
32
+ return cls(int(m.group(1)), int(m.group(2)), int(m.group(3)))
33
+
34
+ def __str__(self) -> str:
35
+ return f"{self.major}.{self.minor}.{self.patch}"
36
+
37
+ def __lt__(self, other: "Version") -> bool:
38
+ return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
39
+
40
+ def bump(self, kind: str) -> "Version":
41
+ if kind == "major":
42
+ return Version(self.major + 1, 0, 0)
43
+ if kind == "minor":
44
+ return Version(self.major, self.minor + 1, 0)
45
+ if kind == "patch":
46
+ return Version(self.major, self.minor, self.patch + 1)
47
+ raise GitcadError(f"unknown bump kind {kind!r}")
48
+
49
+ def actual_bump_from(self, old: "Version") -> str | None:
50
+ """What kind of bump this version is relative to ``old`` — or None if
51
+ it isn't a single well-formed bump (arbitrary jumps are allowed but
52
+ classified by the highest changed field)."""
53
+ if self <= old:
54
+ return None
55
+ if self.major > old.major:
56
+ return "major"
57
+ if self.minor > old.minor:
58
+ return "minor"
59
+ return "patch"
60
+
61
+
62
+ def satisfies(version: Version | str, constraint: str) -> bool:
63
+ """Does ``version`` satisfy ``constraint``? (`^x.y.z`, `~x.y.z`, exact, `*`)"""
64
+ v = Version.parse(version) if isinstance(version, str) else version
65
+ c = constraint.strip()
66
+ if c == "*":
67
+ return True
68
+ if c.startswith("^"):
69
+ base = Version.parse(c[1:])
70
+ if base.major > 0:
71
+ return base <= v < Version(base.major + 1, 0, 0)
72
+ if base.minor > 0:
73
+ # ^0.y.z: 0.x treats minor as breaking (npm/cargo convention)
74
+ return base <= v < Version(0, base.minor + 1, 0)
75
+ # ^0.0.z: exact only — every 0.0.x may break (npm/cargo convention;
76
+ # deviation was flagged in the 2026-07-22 review)
77
+ return v == base
78
+ if c.startswith("~"):
79
+ base = Version.parse(c[1:])
80
+ return base <= v < Version(base.major, base.minor + 1, 0)
81
+ return v == Version.parse(c)
@@ -0,0 +1,20 @@
1
+ """Privacy-preserving bug reporting.
2
+
3
+ The rule (ADR-0007): a submitted issue must include a *repro*, but users will not
4
+ upload proprietary designs — so the local agent **reduces** the failing model to
5
+ a minimal, synthetic case unrelated to the user's work, and the user approves the
6
+ exact payload before anything leaves the machine.
7
+
8
+ - :mod:`gitcad.report.fingerprint` — deterministic failure keys for dedup.
9
+ - :mod:`gitcad.report.reduce` — delta-debugging reducer (the local "recreate it
10
+ in a simple design" step, done by an agent, not a human).
11
+ """
12
+
13
+ from gitcad.report.fingerprint import fingerprint
14
+
15
+ # reduce/scrub operate on mech Documents and import gitcad.document — they are
16
+ # NOT re-exported here so gitcad-core stays importable standalone (the registry
17
+ # installs core only). Import them directly: gitcad.report.reduce / .scrub —
18
+ # which requires gitcad-mech installed.
19
+
20
+ __all__ = ["fingerprint"]
@@ -0,0 +1,22 @@
1
+ """Deterministic failure fingerprints.
2
+
3
+ A fingerprint is the dedup key: 10,000 users hitting one kernel bug must produce
4
+ one issue. It is derived only from *what went wrong* — the failing operation and
5
+ the kernel's own diagnostic class — never from user coordinates, so it is both
6
+ safe to transmit and stable across unrelated models (ADR-0006/0007).
7
+
8
+ The occurrence count of a fingerprint is the priority signal — strictly better
9
+ triage than raw report volume.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+
16
+ from gitcad.errors import FailureSignature
17
+
18
+
19
+ def fingerprint(signature: FailureSignature) -> str:
20
+ """A short, stable id for a failure class. Same (kernel, op, diagnostic) →
21
+ same fingerprint, on every machine."""
22
+ return "fp_" + hashlib.blake2b(signature.key().encode(), digest_size=10).hexdigest()
@@ -0,0 +1,88 @@
1
+ """Delta-debugging reducer — proprietary model → minimal synthetic repro.
2
+
3
+ This is the "someone local recreates the bug in a simple, unrelated design" step
4
+ from the design discussion, done automatically. It is a near-perfect agent task:
5
+ a mechanical search with a crisp, deterministic oracle ("does the failure still
6
+ fingerprint the same?"), so no judgment is required and every step is verifiable.
7
+
8
+ Algorithm: a ddmin-style greedy minimization over the feature list. Repeatedly
9
+ try removing subsets of features; keep any removal that *preserves the same
10
+ failure fingerprint*. Terminate at a 1-minimal document (no single further
11
+ feature can be removed without changing the failure).
12
+
13
+ IMPORTANT: ``ReductionResult.minimal`` is a subset of the user's actual
14
+ features with the user's actual dimensions — NOT yet safe to transmit. The
15
+ transmit-safety gate is :func:`gitcad.report.scrub.prepare_submission`
16
+ (scrub + verify-twice, ADR-0007 steps 3-4).
17
+
18
+ Nothing here transmits anything. The output is a candidate payload for the user
19
+ to inspect and approve (ADR-0007).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Callable
26
+
27
+ from gitcad.document import Document, Feature
28
+
29
+ # An oracle takes a candidate document and returns the failure fingerprint it
30
+ # produces, or None if it no longer fails. Supplied by the caller so the reducer
31
+ # stays kernel-agnostic and testable with a synthetic oracle.
32
+ Oracle = Callable[[Document], "str | None"]
33
+
34
+
35
+ @dataclass
36
+ class ReductionResult:
37
+ minimal: Document
38
+ fingerprint: str
39
+ original_size: int
40
+ minimal_size: int
41
+ steps: int
42
+
43
+
44
+ def _rebuild(features: list[Feature]) -> Document:
45
+ """Rebuild a document from a feature subset, dropping features whose inputs
46
+ were removed (so the tree stays well-formed after deletions)."""
47
+ doc = Document()
48
+ kept: set[str] = set()
49
+ for f in features:
50
+ if all(ref in kept for ref in f.inputs):
51
+ # Re-add preserving the original id for stable fingerprinting.
52
+ doc._features.append(f) # noqa: SLF001 - internal rebuild
53
+ doc._by_id[f.id] = f # noqa: SLF001
54
+ kept.add(f.id)
55
+ return doc
56
+
57
+
58
+ def reduce_document(doc: Document, oracle: Oracle) -> ReductionResult:
59
+ """Greedily minimize ``doc`` while preserving its failure fingerprint."""
60
+ target = oracle(doc)
61
+ if target is None:
62
+ raise ValueError("document does not reproduce a failure; nothing to reduce")
63
+
64
+ features = list(doc.features)
65
+ steps = 0
66
+ changed = True
67
+ while changed:
68
+ changed = False
69
+ i = 0
70
+ while i < len(features):
71
+ candidate_features = features[:i] + features[i + 1 :]
72
+ candidate = _rebuild(candidate_features)
73
+ steps += 1
74
+ if oracle(candidate) == target:
75
+ # Removing feature i preserved the failure — keep it removed.
76
+ features = list(candidate.features)
77
+ changed = True
78
+ else:
79
+ i += 1
80
+
81
+ minimal = _rebuild(features)
82
+ return ReductionResult(
83
+ minimal=minimal,
84
+ fingerprint=target,
85
+ original_size=len(doc),
86
+ minimal_size=len(minimal),
87
+ steps=steps,
88
+ )
gitcad/report/scrub.py ADDED
@@ -0,0 +1,116 @@
1
+ """Scrub + similarity check — ADR-0007 steps 3 and 4, the transmit-safety gate.
2
+
3
+ The reducer (:mod:`.reduce`) produces a *subset of the user's actual features
4
+ with the user's actual dimensions* — NOT yet safe to transmit. This module
5
+ finishes the pipeline:
6
+
7
+ - :func:`scrub` rewrites the minimal repro with sanitized dimensions (rounded
8
+ to coarse values), stripped non-essential string params (file paths, names),
9
+ and re-minted ids — then re-runs the oracle to confirm the failure survived
10
+ sanitization.
11
+ - :func:`similarity` measures how much of the original design leaks into the
12
+ candidate: surviving feature fraction and shared exact dimension values.
13
+
14
+ :func:`prepare_submission` combines both into a verdict: a payload is
15
+ ``transmit_safe`` only if the scrubbed repro still fingerprints identically
16
+ AND resembles the original weakly enough. When it isn't safe, the honest
17
+ fallback is a fingerprint-only report (ADR-0007) — never "ship it anyway".
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+
24
+ from gitcad.document import Document, Feature
25
+ from gitcad.report.reduce import Oracle, ReductionResult, reduce_document
26
+
27
+ # String params that carry user context and are never needed to reproduce a
28
+ # geometry-kernel failure class.
29
+ _STRING_DENYLIST = {"file", "sha256", "name", "label", "comment"}
30
+
31
+ # Feature-count and dimension-overlap ceilings for "does not resemble the
32
+ # original". Conservative on purpose; ADR-0007 says precision over recall.
33
+ _MAX_SURVIVING_FRACTION = 0.34
34
+ _MAX_DIM_OVERLAP = 0.5
35
+ # A repro this small is inherently non-identifying — the fraction test only
36
+ # applies above it (2 features of a 5-feature doc is 40% but reveals nothing;
37
+ # 200 features of a 500-feature design is a different matter).
38
+ _SMALL_REPRO_FEATURES = 5
39
+
40
+
41
+ def _scrub_value(v, ndigits: int):
42
+ if isinstance(v, bool):
43
+ return v
44
+ if isinstance(v, (int, float)):
45
+ return round(float(v), ndigits)
46
+ if isinstance(v, (list, tuple)):
47
+ return [_scrub_value(x, ndigits) for x in v]
48
+ return v
49
+
50
+
51
+ def scrub(doc: Document, *, ndigits: int = 1) -> Document:
52
+ """Sanitized copy: dimensions coarsened, contextual strings stripped,
53
+ ids re-minted from the scrubbed values."""
54
+ out = Document()
55
+ id_map: dict[str, str] = {}
56
+ for f in doc.features:
57
+ params = {k: _scrub_value(v, ndigits)
58
+ for k, v in f.params.items() if k not in _STRING_DENYLIST}
59
+ new_id = out.add(Feature(op=f.op, params=params,
60
+ inputs=[id_map[i] for i in f.inputs]))
61
+ id_map[f.id] = new_id
62
+ return out
63
+
64
+
65
+ def similarity(original: Document, candidate: Document) -> dict[str, float]:
66
+ """How much of the original leaks into the candidate. Auditable numbers,
67
+ not a verdict — :func:`prepare_submission` applies the thresholds."""
68
+ surviving = len(candidate) / len(original) if len(original) else 0.0
69
+ orig_dims = {round(float(v), 6) for f in original.features
70
+ for v in f.params.values() if isinstance(v, (int, float)) and not isinstance(v, bool)}
71
+ cand_dims = {round(float(v), 6) for f in candidate.features
72
+ for v in f.params.values() if isinstance(v, (int, float)) and not isinstance(v, bool)}
73
+ dim_overlap = (len(orig_dims & cand_dims) / len(cand_dims)) if cand_dims else 0.0
74
+ return {"surviving_feature_fraction": round(surviving, 3),
75
+ "dimension_overlap": round(dim_overlap, 3)}
76
+
77
+
78
+ @dataclass
79
+ class Submission:
80
+ """The candidate bug-report payload plus its transmit-safety verdict."""
81
+
82
+ fingerprint: str
83
+ reduction: ReductionResult
84
+ scrubbed: Document | None
85
+ transmit_safe: bool
86
+ reasons: list[str] = field(default_factory=list)
87
+ metrics: dict[str, float] = field(default_factory=dict)
88
+
89
+
90
+ def prepare_submission(doc: Document, oracle: Oracle) -> Submission:
91
+ """Full ADR-0007 pipeline: reduce → scrub → verify twice.
92
+
93
+ ``transmit_safe=False`` means: submit the fingerprint only. The scrubbed
94
+ document is still returned for LOCAL debugging either way — it just must
95
+ not leave the machine unless safe.
96
+ """
97
+ reduction = reduce_document(doc, oracle)
98
+ reasons: list[str] = []
99
+
100
+ scrubbed = scrub(reduction.minimal)
101
+ # Verify #1: the failure must survive sanitization (some bugs need exact
102
+ # dimensions — then the repro is inherently user data and cannot ship).
103
+ if oracle(scrubbed) != reduction.fingerprint:
104
+ reasons.append("failure-does-not-survive-scrubbing")
105
+ return Submission(reduction.fingerprint, reduction, scrubbed, False, reasons)
106
+
107
+ # Verify #2: the scrubbed repro must not resemble the original design.
108
+ metrics = similarity(doc, scrubbed)
109
+ if (len(scrubbed) > _SMALL_REPRO_FEATURES
110
+ and metrics["surviving_feature_fraction"] > _MAX_SURVIVING_FRACTION):
111
+ reasons.append("resembles-original:feature-fraction")
112
+ if metrics["dimension_overlap"] > _MAX_DIM_OVERLAP:
113
+ reasons.append("resembles-original:dimension-overlap")
114
+
115
+ return Submission(reduction.fingerprint, reduction, scrubbed,
116
+ transmit_safe=not reasons, reasons=reasons, metrics=metrics)