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.
- gitcad/_version.py +1 -0
- gitcad/canonical.py +56 -0
- gitcad/errors.py +84 -0
- gitcad/expr.py +148 -0
- gitcad/identity.py +134 -0
- gitcad/importers/report.py +31 -0
- gitcad/part/__init__.py +29 -0
- gitcad/part/assembly.py +229 -0
- gitcad/part/bought.py +62 -0
- gitcad/part/exploded.py +106 -0
- gitcad/part/interface.py +182 -0
- gitcad/part/interference.py +71 -0
- gitcad/part/lockfile.py +133 -0
- gitcad/part/manifest.py +77 -0
- gitcad/part/matesolve.py +88 -0
- gitcad/part/semver.py +81 -0
- gitcad/report/__init__.py +20 -0
- gitcad/report/fingerprint.py +22 -0
- gitcad/report/reduce.py +88 -0
- gitcad/report/scrub.py +116 -0
- gitcad/seams.py +249 -0
- gitcad/strokefont.py +84 -0
- gitcad/waivers.py +59 -0
- gitcad_core-0.7.7.dist-info/METADATA +40 -0
- gitcad_core-0.7.7.dist-info/RECORD +26 -0
- gitcad_core-0.7.7.dist-info/WHEEL +4 -0
gitcad/part/lockfile.py
ADDED
|
@@ -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
|
gitcad/part/manifest.py
ADDED
|
@@ -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()
|
gitcad/part/matesolve.py
ADDED
|
@@ -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()
|
gitcad/report/reduce.py
ADDED
|
@@ -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)
|