adopt-detect 0.3.0__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,67 @@
1
+ """Archetype detection, tier negotiation, the observability boundary.
2
+
3
+ Implementation spec §4.11, PRD F10, sprint S6.
4
+
5
+ Four invariants hold across this package and each is a refusal rather than a
6
+ preference:
7
+
8
+ 1. **No model call on the deterministic path.** `04` §4 runs the walk to
9
+ completion; below `DETECT_CONFIDENCE_MIN` the answer is *ambiguous with
10
+ ranked scores*, never a guess. There is no call site for a model here.
11
+ 2. **No code execution in the target tree.** Files are read, at most
12
+ `DETECT_MAX_SNIFF_BYTES` each, and matched against literal substrings.
13
+ 3. **No network.** Detection is pure filesystem, and
14
+ `tests/property/test_offline.py` measures that rather than asserting it.
15
+ 4. **Archetype values are exactly `web|platform|lowcode|data|ai`**, read from
16
+ the generated enum rather than retyped.
17
+
18
+ The tier ladder is **CR-38**, ratified 2026-08-05: three qualification questions
19
+ to `T0`-`T4`, with `T0` a decline recommendation and `T3` the floor the `ai`
20
+ archetype requires.
21
+ """
22
+
23
+ from adopt_detect.boundary import (
24
+ DEFAULT_OUTBOUND_CATEGORIES,
25
+ METADATA_ONLY,
26
+ BoundaryView,
27
+ declare_boundary,
28
+ )
29
+ from adopt_detect.detect import DetectionResult, RuleHit, detect
30
+ from adopt_detect.negotiate import (
31
+ AI_MINIMUM_TIER,
32
+ QUESTIONS,
33
+ Answers,
34
+ TierDecision,
35
+ negotiate,
36
+ parse_answers,
37
+ unavailable_capabilities,
38
+ violates_archetype_floor,
39
+ )
40
+ from adopt_detect.records import BoundaryRecords
41
+ from adopt_detect.render import render_json, render_markdown
42
+ from adopt_detect.rules import ARCHETYPES, ArchetypeRules, Rule, load_rule_sets
43
+
44
+ __all__ = [
45
+ "AI_MINIMUM_TIER",
46
+ "ARCHETYPES",
47
+ "DEFAULT_OUTBOUND_CATEGORIES",
48
+ "METADATA_ONLY",
49
+ "QUESTIONS",
50
+ "Answers",
51
+ "ArchetypeRules",
52
+ "BoundaryRecords",
53
+ "BoundaryView",
54
+ "DetectionResult",
55
+ "Rule",
56
+ "RuleHit",
57
+ "TierDecision",
58
+ "declare_boundary",
59
+ "detect",
60
+ "load_rule_sets",
61
+ "negotiate",
62
+ "parse_answers",
63
+ "render_json",
64
+ "render_markdown",
65
+ "unavailable_capabilities",
66
+ "violates_archetype_floor",
67
+ ]
@@ -0,0 +1,196 @@
1
+ """The observability boundary: one row, two renderings, one authority.
2
+
3
+ PRD F10.5-F10.8 and contracts §3. The boundary is the object that **hard-limits
4
+ every downstream claim**: what may be observed, where knowledge lives, and what
5
+ may leave. Three things about it are load-bearing.
6
+
7
+ **`BoundaryView` is the accessor every downstream item calls** (`05` S6). It is a
8
+ read-only projection of one stored row, and it is the type contracts §8 names in
9
+ `validate_envelope(env, boundary: BoundaryView)`. Its one home is here, so that
10
+ "what may leave" has one answer rather than one per caller.
11
+
12
+ **The boundary is the authority, never the caller's declaration** (contracts §8
13
+ rule 3, PRD F12.5). `permitted_outbound_categories` lives on the row precisely so
14
+ that widening it is a write to a client-signed artefact rather than a
15
+ configuration change -- and `contractual_approval_ref` is what records the
16
+ amendment that permitted it.
17
+
18
+ **Both rendered artifacts come from one row** (F10.8). `render.py` takes a
19
+ `BoundaryView` and nothing else; there is no path by which the human-readable
20
+ statement could say something the machine-readable one does not. Two renderers
21
+ reading two sources is how a client is shown a document that the system does not
22
+ enforce.
23
+ """
24
+
25
+ import datetime as _dt
26
+ from collections.abc import Sequence
27
+ from dataclasses import dataclass
28
+ from typing import Final
29
+
30
+ from adopt_detect.negotiate import TierDecision, violates_archetype_floor
31
+ from adopt_detect.records import BoundaryRecords
32
+ from adopt_model import ObservabilityBoundary
33
+ from adopt_model._enums import Archetype, ControlPlane, KnowledgePlane, Tier
34
+ from adopt_scope import Scope
35
+
36
+ __all__ = [
37
+ "DEFAULT_OUTBOUND_CATEGORIES",
38
+ "METADATA_ONLY",
39
+ "BoundaryView",
40
+ "declare_boundary",
41
+ ]
42
+
43
+ #: Contracts §8 rule 1 and the `observability_boundary` column default. The one
44
+ #: policy that needs no contract amendment, and the value everything falls back
45
+ #: to when nothing else was agreed.
46
+ METADATA_ONLY: Final[str] = "metadata_only"
47
+
48
+ #: PRD F10.5. The default is a *list* because the column is a JSON list, and it
49
+ #: is a one-element list rather than an empty one because an empty permitted set
50
+ #: would make every outbound envelope invalid -- including the metadata-only ones
51
+ #: the product is built to send.
52
+ DEFAULT_OUTBOUND_CATEGORIES: Final[tuple[str, ...]] = (METADATA_ONLY,)
53
+
54
+ #: Local-first is the default posture: client knowledge stays with the customer
55
+ #: and only routing lives with the vendor. Both are overridable per engagement,
56
+ #: and both are recorded on the row rather than inferred at read time.
57
+ DEFAULT_KNOWLEDGE_PLANE: Final[KnowledgePlane] = "customer"
58
+ DEFAULT_CONTROL_PLANE: Final[ControlPlane] = "vendor"
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class BoundaryView:
63
+ """A read-only projection of one `observability_boundary` row.
64
+
65
+ Contracts §8's `validate_envelope` second parameter. Frozen, because a
66
+ validator that could widen the boundary it is validating against is not a
67
+ validator.
68
+ """
69
+
70
+ boundary_id: str
71
+ system_id: str
72
+ environment_id: str | None
73
+ tier: Tier
74
+ archetype: Archetype | None
75
+ knowledge_plane_location: KnowledgePlane
76
+ control_plane_location: ControlPlane
77
+ permitted_outbound_categories: tuple[str, ...]
78
+ unavailable_capabilities: tuple[str, ...]
79
+ contractual_approval_ref: str | None
80
+ declared_at: _dt.datetime
81
+ decline_recommended: bool
82
+ archetype_floor_violated: bool
83
+ last_successful_observation_at: _dt.datetime | None = None
84
+ safe_probe_status: str | None = None
85
+
86
+ def permits(self, content_policy: str) -> bool:
87
+ """Whether this boundary permits a policy. **The authority.**"""
88
+ return content_policy in self.permitted_outbound_categories
89
+
90
+ @classmethod
91
+ def of(
92
+ cls,
93
+ row: ObservabilityBoundary,
94
+ *,
95
+ archetype: Archetype | None,
96
+ unavailable_capabilities: Sequence[str] = (),
97
+ decline_recommended: bool = False,
98
+ archetype_floor_violated: bool = False,
99
+ ) -> "BoundaryView":
100
+ """Project a stored row.
101
+
102
+ `archetype` is carried alongside rather than read from the row because
103
+ `observability_boundary` has no archetype column -- it is `system`'s, and
104
+ contracts §3 puts it there. Passing it explicitly keeps the view honest
105
+ about where each field came from instead of implying a column that does
106
+ not exist.
107
+ """
108
+ categories = row.permitted_outbound_categories
109
+ return cls(
110
+ boundary_id=row.id,
111
+ system_id=row.system_id,
112
+ environment_id=row.environment_id,
113
+ tier=row.tier,
114
+ archetype=archetype,
115
+ knowledge_plane_location=row.knowledge_plane_location,
116
+ control_plane_location=row.control_plane_location,
117
+ permitted_outbound_categories=tuple(categories) if isinstance(categories, list) else (),
118
+ unavailable_capabilities=tuple(unavailable_capabilities),
119
+ contractual_approval_ref=row.contractual_approval_ref,
120
+ declared_at=row.declared_at,
121
+ decline_recommended=decline_recommended,
122
+ archetype_floor_violated=archetype_floor_violated,
123
+ last_successful_observation_at=row.last_successful_observation_at,
124
+ safe_probe_status=row.safe_probe_status,
125
+ )
126
+
127
+
128
+ def declare_boundary(
129
+ records: BoundaryRecords,
130
+ *,
131
+ scope: Scope,
132
+ decision: TierDecision,
133
+ archetype: Archetype | None,
134
+ knowledge_plane_location: KnowledgePlane = DEFAULT_KNOWLEDGE_PLANE,
135
+ control_plane_location: ControlPlane = DEFAULT_CONTROL_PLANE,
136
+ permitted_outbound_categories: Sequence[str] = DEFAULT_OUTBOUND_CATEGORIES,
137
+ contractual_approval_ref: str | None = None,
138
+ owner_actor_id: str | None = None,
139
+ safe_probe_status: str | None = None,
140
+ ) -> BoundaryView:
141
+ """Write the boundary a negotiation implies and return its view.
142
+
143
+ Args:
144
+ records: The storage port. Writes the row; decides nothing.
145
+ scope: Must resolve at least to a system.
146
+ decision: The negotiated tier, from `negotiate`.
147
+ archetype: What detection concluded, or `None` when it was ambiguous.
148
+ knowledge_plane_location: Where client knowledge lives.
149
+ control_plane_location: Where routing and entitlement live.
150
+ permitted_outbound_categories: What may leave. Defaults to
151
+ `["metadata_only"]`; anything wider needs `contractual_approval_ref`.
152
+ contractual_approval_ref: The contract amendment reference.
153
+ owner_actor_id: Who is accountable.
154
+ safe_probe_status: Whether a safe execution path exists.
155
+
156
+ Returns:
157
+ The `BoundaryView` for the row just written.
158
+
159
+ Raises:
160
+ AdoptError: ``SCOPE_VIOLATION`` from the facade when the scope resolves
161
+ no system.
162
+
163
+ Note:
164
+ **A `T0` decision still writes a boundary.** The decline recommendation
165
+ is a *finding about* the engagement, not a refusal to record what was
166
+ negotiated -- and an engagement that was declined is exactly the one
167
+ whose boundary someone will want to read six months later.
168
+ """
169
+ categories = tuple(permitted_outbound_categories)
170
+ row = records.declare(
171
+ scope=scope,
172
+ tier=decision.tier,
173
+ knowledge_plane_location=knowledge_plane_location,
174
+ control_plane_location=control_plane_location,
175
+ permitted_outbound_categories=categories,
176
+ covered=decision.rationale,
177
+ not_covered=_not_covered(decision.unavailable),
178
+ safe_probe_status=safe_probe_status,
179
+ owner_actor_id=owner_actor_id,
180
+ contractual_approval_ref=contractual_approval_ref,
181
+ contractual=contractual_approval_ref is not None,
182
+ )
183
+ return BoundaryView.of(
184
+ row,
185
+ archetype=archetype,
186
+ unavailable_capabilities=decision.unavailable,
187
+ decline_recommended=decision.decline_recommended,
188
+ archetype_floor_violated=violates_archetype_floor(archetype, decision.tier),
189
+ )
190
+
191
+
192
+ def _not_covered(unavailable: Sequence[str]) -> str:
193
+ """The human-readable summary of what this tier does not grant."""
194
+ if not unavailable:
195
+ return "Nothing: every capability the platform offers is available at this tier."
196
+ return "Not available at this tier: " + ", ".join(unavailable) + "."
adopt_detect/detect.py ADDED
@@ -0,0 +1,323 @@
1
+ """Archetype detection: a bounded, deterministic walk over a file tree.
2
+
3
+ PRD F10.1-F10.3 and implementation spec §4.11. Four properties are load-bearing
4
+ and each is a refusal rather than a preference:
5
+
6
+ **No code executes in the target tree.** Nothing here imports, evaluates or runs
7
+ anything it finds. Files are opened for reading, at most
8
+ `DETECT_MAX_SNIFF_BYTES` of each, and matched against literal substrings. This
9
+ is what lets an FDE point the tool at a client repository they do not own.
10
+
11
+ **No network.** Detection is pure filesystem. `01` N10 makes that measurable
12
+ rather than asserted; `tests/property/test_offline.py` is the instrument.
13
+
14
+ **No model call in this module.** `04` §4 is explicit: the deterministic path
15
+ runs to completion, and below `DETECT_CONFIDENCE_MIN` the answer is *ambiguous
16
+ with ranked scores*, never a guess. The model-backed disambiguation pass lives in
17
+ `adopt_detect.disambiguate`, is reached only with `ADOPT_FEATURE_AGENT_DISAMBIGUATION`
18
+ on **and** a runner handed in, and **there is still no call site for it here** --
19
+ `detect()` cannot reach a model even transitively, which is what makes steps 1-3
20
+ of `04` §4 model-free by construction rather than by intent.
21
+
22
+ `bounded_listing` is the one thing this module lends that pass: the evidence a
23
+ proposal is allowed to see includes a directory listing, and it must be *this*
24
+ walk's listing -- same bounds, same `.gitignore` scope, same symlink refusal, same
25
+ deterministic order. A second walk written next to the prompt would be a second
26
+ answer to "what is in this tree", and the one that drifted would be the one that
27
+ decided what left the environment.
28
+
29
+ **Byte-identical across runs and machines** (`01` N2, N15). Every ordering in
30
+ this module is explicit: entries are sorted before the walk, rules are scored in
31
+ declaration order, and the archetype ranking breaks ties by the fixed
32
+ `ARCHETYPES` order. Nothing depends on filesystem iteration order, on a `set`'s
33
+ layout, or on a dict built from either.
34
+
35
+ **The walk is bounded three ways**, and hitting a bound is reported rather than
36
+ silently truncating the evidence: `DETECT_MAX_DEPTH` on nesting,
37
+ `DETECT_MAX_FILES` on files considered, `DETECT_MAX_SNIFF_BYTES` on bytes read
38
+ per file. A client monorepo is allowed to be enormous; it is not allowed to make
39
+ detection unbounded.
40
+ """
41
+
42
+ import os
43
+ from collections.abc import Iterator, Sequence
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path, PurePosixPath
46
+ from typing import Final
47
+
48
+ from adopt_const import DETECT_CONFIDENCE_MIN, DETECT_MAX_DEPTH, DETECT_MAX_FILES
49
+ from adopt_const import DETECT_MAX_SNIFF_BYTES as _SNIFF_BYTES
50
+ from adopt_detect.gitignore import GitignoreFilter
51
+ from adopt_detect.rules import ARCHETYPES, ArchetypeRules, load_rule_sets, needs_content
52
+ from adopt_model._enums import Archetype
53
+ from adopt_obs import AdoptError, ErrorCode
54
+
55
+ __all__ = ["DetectionResult", "RuleHit", "bounded_listing", "detect"]
56
+
57
+ #: The flag whose name the ambiguity report prints. It is named here, not
58
+ #: implemented here: `04` §4 step 4 is a later sprint's, and the re-run hint has
59
+ #: to name the flag a caller would actually set.
60
+ DISAMBIGUATION_FLAG: Final[str] = "ADOPT_FEATURE_AGENT_DISAMBIGUATION"
61
+
62
+ #: Directories never descended into. `.git` is the one that matters for
63
+ #: correctness rather than speed: its object store contains compressed copies of
64
+ #: everything, and a `contains` rule would fire on packfile bytes that no longer
65
+ #: reflect the working tree.
66
+ _ALWAYS_SKIPPED: Final[frozenset[str]] = frozenset(
67
+ {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache"}
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class RuleHit:
73
+ """One rule that fired, and the first path that fired it."""
74
+
75
+ archetype: Archetype
76
+ rule_id: str
77
+ path: str
78
+ weight: float
79
+ why: str
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class DetectionResult:
84
+ """What detection concluded, and everything it concluded it from.
85
+
86
+ `archetype` is `None` exactly when `ambiguous` is true. The two are kept as
87
+ separate fields rather than one optional because callers branch on ambiguity
88
+ and reading `archetype is None` as "ambiguous" invites the other reading,
89
+ "not yet computed".
90
+ """
91
+
92
+ root: str
93
+ archetype: Archetype | None
94
+ confidence: float
95
+ scores: dict[Archetype, float]
96
+ rules_fired: tuple[RuleHit, ...]
97
+ files_considered: int
98
+ truncated: bool
99
+ ambiguous: bool = field(default=False)
100
+
101
+ def ranked(self) -> tuple[tuple[Archetype, float], ...]:
102
+ """Archetypes by score descending, ties broken by declaration order."""
103
+ return tuple(
104
+ sorted(self.scores.items(), key=lambda pair: (-pair[1], ARCHETYPES.index(pair[0])))
105
+ )
106
+
107
+
108
+ def _sorted_entries(directory: Path) -> list[os.DirEntry[str]]:
109
+ """Directory entries in one fixed order, whatever the filesystem returns.
110
+
111
+ `scandir` order is filesystem- and machine-dependent. Sorting here is what
112
+ makes `rules_fired` -- which records the *first* path that fired each rule --
113
+ the same on a developer laptop and on the reference runner.
114
+ """
115
+ with os.scandir(directory) as entries:
116
+ return sorted(entries, key=lambda entry: entry.name)
117
+
118
+
119
+ def _is_within(candidate: Path, root: Path) -> bool:
120
+ """Whether a resolved path is inside a resolved root.
121
+
122
+ The symlink check. A client tree may legitimately contain a symlink to
123
+ somewhere outside itself, and following one would let detection read files
124
+ the operator never pointed us at -- and, under a `contains` rule, let content
125
+ outside the tree change the archetype. Such a link is skipped, not an error:
126
+ refusing the whole tree over one stray link would make the tool unusable on
127
+ real repositories.
128
+ """
129
+ return candidate == root or root in candidate.parents
130
+
131
+
132
+ def _walk(root: Path, ignore: GitignoreFilter) -> Iterator[tuple[str, Path, bool]]:
133
+ """Yield `(relative_posix_path, absolute_path, is_file)` breadth-first.
134
+
135
+ Breadth-first so that a bound is hit at the *bottom* of the tree rather than
136
+ inside whichever subdirectory happened to sort first -- a depth-first walk
137
+ that stops at `DETECT_MAX_FILES` on a monorepo would see all of one package
138
+ and none of the others, which is how a bound turns into a wrong answer
139
+ instead of a partial one.
140
+ """
141
+ queue: list[tuple[Path, str, int]] = [(root, "", 0)]
142
+ while queue:
143
+ directory, prefix, depth = queue.pop(0)
144
+ try:
145
+ entries = _sorted_entries(directory)
146
+ except OSError:
147
+ # Unreadable directory: skipped, never fatal. A client checkout with
148
+ # one permission-denied path is still a tree we can classify.
149
+ continue
150
+ for entry in entries:
151
+ relative = f"{prefix}{entry.name}"
152
+ absolute = Path(entry.path)
153
+ if entry.is_symlink() and not _is_within(absolute.resolve(), root):
154
+ continue
155
+ if entry.is_dir(follow_symlinks=False):
156
+ if entry.name in _ALWAYS_SKIPPED or ignore.is_ignored(relative, is_dir=True):
157
+ continue
158
+ if depth < DETECT_MAX_DEPTH:
159
+ queue.append((absolute, f"{relative}/", depth + 1))
160
+ continue
161
+ if ignore.is_ignored(relative, is_dir=False):
162
+ continue
163
+ yield relative, absolute, True
164
+
165
+
166
+ def bounded_listing(root: Path | str, *, limit: int) -> tuple[tuple[str, ...], bool]:
167
+ """The first `limit` paths this walk would consider. Returns `(paths, truncated)`.
168
+
169
+ **Paths only.** No file is opened, so nothing here can carry file contents --
170
+ which is the `04` §4 step 4 privacy invariant, held by the function not having
171
+ the capability rather than by its caller remembering not to use it.
172
+
173
+ Truncation is reported rather than silent, for the reason `DetectionResult`
174
+ reports it: a proposal reasoning over a listing that stopped somewhere is
175
+ reasoning over less evidence than it appears to have, and a model told the
176
+ listing was truncated can say so in its confidence.
177
+ """
178
+ # Resolved and filtered exactly as `detect()` does it, three lines below.
179
+ # Copying the two lines rather than sharing them is the smaller risk: sharing
180
+ # would mean a helper both call, and the thing that must not drift is the
181
+ # *bounds and the filter*, which are named constants and one classmethod.
182
+ resolved = Path(root).resolve()
183
+ ignore = GitignoreFilter.for_tree(resolved)
184
+ paths: list[str] = []
185
+ for relative, _absolute, _is_file in _walk(resolved, ignore):
186
+ if len(paths) >= limit:
187
+ return tuple(paths), True
188
+ paths.append(relative)
189
+ return tuple(paths), False
190
+
191
+
192
+ def _head(path: Path) -> bytes | None:
193
+ """The first `DETECT_MAX_SNIFF_BYTES`, or `None` if unreadable."""
194
+ try:
195
+ with path.open("rb") as handle:
196
+ return handle.read(_SNIFF_BYTES)
197
+ except OSError:
198
+ return None
199
+
200
+
201
+ def detect(
202
+ root: Path | str, *, rule_sets: Sequence[ArchetypeRules] | None = None
203
+ ) -> DetectionResult:
204
+ """Classify a file tree into exactly one archetype, or report ambiguity.
205
+
206
+ Args:
207
+ root: The tree to classify. Never read as code, never executed.
208
+ rule_sets: Injected rule sets, for tests that need a small closed corpus.
209
+ Defaults to the packaged `rules/*.yaml`.
210
+
211
+ Returns:
212
+ A `DetectionResult`. When the winning score is below
213
+ `DETECT_CONFIDENCE_MIN` the result is `ambiguous` with `archetype` of
214
+ `None` and the full ranked scores -- **never a best guess**. PRD F10.3
215
+ makes that a product statement: a wrong archetype is not a wrong answer
216
+ but a different set of extractors, which nothing downstream can detect.
217
+
218
+ Raises:
219
+ AdoptError: ``DETECT_AMBIGUOUS`` is *not* raised here -- ambiguity is a
220
+ result, and the CLI maps it to exit `2`. This function raises only
221
+ ``MANIFEST_INVALID``, from rule loading, and
222
+ ``ADOPT_CONFIG_UNRESOLVED`` when `root` is not a directory.
223
+ """
224
+ tree = Path(root)
225
+ if not tree.is_dir():
226
+ raise AdoptError(
227
+ ErrorCode.ADOPT_CONFIG_UNRESOLVED,
228
+ message=f"{tree} is not a directory",
229
+ hint="Detection classifies a file tree. Point it at a checkout root.",
230
+ )
231
+ resolved = tree.resolve()
232
+ sets = tuple(rule_sets) if rule_sets is not None else load_rule_sets()
233
+ read_content = needs_content(sets)
234
+ ignore = GitignoreFilter.for_tree(resolved)
235
+
236
+ hits: dict[tuple[Archetype, str], RuleHit] = {}
237
+ files_considered = 0
238
+ truncated = False
239
+
240
+ for relative, absolute, _ in _walk(resolved, ignore):
241
+ if files_considered >= DETECT_MAX_FILES:
242
+ truncated = True
243
+ break
244
+ files_considered += 1
245
+ head: bytes | None = None
246
+ head_read = False
247
+ for rule_set in sets:
248
+ for rule in rule_set.rules:
249
+ key = (rule_set.archetype, rule.id)
250
+ if key in hits:
251
+ continue
252
+ if rule.contains is not None:
253
+ if not read_content: # pragma: no cover -- defensive
254
+ continue
255
+ if not head_read:
256
+ head = _head(absolute)
257
+ head_read = True
258
+ if rule.matches(relative, head):
259
+ hits[key] = RuleHit(
260
+ archetype=rule_set.archetype,
261
+ rule_id=rule.id,
262
+ path=relative,
263
+ weight=rule.weight,
264
+ why=rule.why,
265
+ )
266
+
267
+ scores = _score(sets, hits)
268
+ ranked = sorted(scores.items(), key=lambda pair: (-pair[1], ARCHETYPES.index(pair[0])))
269
+ winner, confidence = ranked[0]
270
+ ambiguous = confidence < DETECT_CONFIDENCE_MIN
271
+
272
+ return DetectionResult(
273
+ root=PurePosixPath(resolved.as_posix()).as_posix(),
274
+ archetype=None if ambiguous else winner,
275
+ confidence=confidence,
276
+ scores=scores,
277
+ rules_fired=tuple(
278
+ sorted(hits.values(), key=lambda hit: (ARCHETYPES.index(hit.archetype), hit.rule_id))
279
+ ),
280
+ files_considered=files_considered,
281
+ truncated=truncated,
282
+ ambiguous=ambiguous,
283
+ )
284
+
285
+
286
+ def _score(
287
+ rule_sets: Sequence[ArchetypeRules], hits: dict[tuple[Archetype, str], RuleHit]
288
+ ) -> dict[Archetype, float]:
289
+ """Each archetype's **share of the total matched weight**, rounded.
290
+
291
+ **The denominator is the evidence found, not the evidence available**, and
292
+ that is the whole design. Dividing by an archetype's own total weight would
293
+ ask "how many of the web rules did this tree fire?" -- and a Django service
294
+ fires none of the Rails, Go or Express rules, so a perfectly clear web system
295
+ would score around 0.3 and be reported ambiguous. The question worth asking
296
+ is "how much of what we found points one way?", which is what
297
+ `DETECT_CONFIDENCE_MIN = 0.70` reads as in prose: seventy per cent of the
298
+ evidence agrees.
299
+
300
+ Two consequences follow, both wanted. Scores sum to 1, so the ranked list in
301
+ an ambiguity report is a genuine distribution rather than five unrelated
302
+ ratios. And a tree that fires **nothing** scores 0.0 everywhere and is
303
+ ambiguous -- the correct answer for a directory with no signals in it, and
304
+ one that costs no special case downstream.
305
+
306
+ **Rounded deliberately.** The score crosses a threshold, is printed as JSON
307
+ and is compared byte-for-byte by `01` N2's determinism property, so a value
308
+ whose last bits depend on summation order would make that property fail for a
309
+ reason that has nothing to do with detection. Six places is far finer than
310
+ `DETECT_CONFIDENCE_MIN`'s two and removes the question.
311
+ """
312
+ matched: dict[Archetype, float] = {
313
+ rule_set.archetype: sum(
314
+ hit.weight for key, hit in hits.items() if key[0] == rule_set.archetype
315
+ )
316
+ for rule_set in rule_sets
317
+ }
318
+ total = sum(matched.values())
319
+ if total == 0:
320
+ return dict.fromkeys(matched, 0.0)
321
+ # const-sync: ok -- 6 is JSON rendering precision for a ratio in [0,1], not a
322
+ # behavioural threshold; `DETECT_CONFIDENCE_MIN` is the threshold.
323
+ return {archetype: round(weight / total, 6) for archetype, weight in matched.items()}