sourcecode 2.1.0__py3-none-any.whl → 2.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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.1.0"
7
+ __version__ = "2.3.0"
@@ -0,0 +1,605 @@
1
+ """Evidence-based architectural archetype classifier (ADDITIVE / EXPERIMENTAL).
2
+
3
+ Runs in PARALLEL with the legacy `ArchitectureAnalyzer` pattern. It does NOT feed
4
+ the report wording yet — it exists so the new model can be validated against the
5
+ legacy one across many repos before any cutover.
6
+
7
+ Design goals (vs the legacy directory-name matcher):
8
+ * describe WHAT a system is, not which technologies it contains;
9
+ * accumulate weighted evidence from many signals — never a single dominant rule;
10
+ * weight every signal by CODE MASS (fraction of the codebase it represents), so a
11
+ directory named `api/` in a 5,600-file engine contributes ~0.003, not 1.0;
12
+ * report per-candidate score + evidence + confidence + a human explanation.
13
+
14
+ Four ORTHOGONAL dimensions (a system is a point in each, not one enum):
15
+ * software_archetype : engine | platform | framework | library | application | compiler | sdk
16
+ * architectural_style: layered | hexagonal | clean | mvc | event-driven | plugin-based | microkernel
17
+ * deployment_shape : monolith | multi-module | distributed | embedded
18
+ * primary_interface : api-first | cli | worker | daemon | library | embedded
19
+
20
+ Signals consumed today (all cheap / already computed by a normal scan):
21
+ module masses (file_tree), package-concept masses (path segments), entry-point
22
+ kinds, HTTP endpoint share, packaging, detected frameworks, and — when present on
23
+ the SourceMap — module_graph_summary (hubs/cycles). Richer signals (repo-ir
24
+ subsystem masses, Semantic-IR SPI fan-in, protocol handlers) are declared in
25
+ `signals_missing` when unavailable so the confidence reflects it.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any, Optional
32
+
33
+ from sourcecode.graph_evidence import GraphEvidence, GraphEvidenceProvider, GraphEvidenceResult
34
+ from sourcecode.schema import SourceMap
35
+ from sourcecode.tree_utils import flatten_file_tree
36
+
37
+ _JVM_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
38
+ _CODE_EXTS = _JVM_EXTS + (".py", ".go", ".rs", ".cs", ".ts", ".js", ".rb", ".php")
39
+ _NON_SOURCE = frozenset({
40
+ "test", "tests", "src-test", "it", "benchmark", "benchmarks", "bench",
41
+ "example", "examples", "demo", "docs", "doc", "fixture", "fixtures",
42
+ "node_modules", "target", "build", "generated", "resources",
43
+ })
44
+
45
+ # Concept banks: path-segment keywords → a named concept. Mass of a concept =
46
+ # fraction of source files whose path contains any of its segments. These are
47
+ # DOMAIN concepts, not framework names; membership is deliberately broad.
48
+ _CONCEPTS: dict[str, frozenset[str]] = {
49
+ "storage": frozenset({"storage", "store", "pagecache", "pagecache", "recordstorage",
50
+ "record", "btree", "gbptree", "wal", "log", "tx", "transaction",
51
+ "commit", "checkpoint", "index", "lucene", "kvstore", "heap"}),
52
+ "kernel": frozenset({"kernel", "engine", "executor", "runtime", "core", "internal"}),
53
+ "compiler": frozenset({"parser", "lexer", "ast", "grammar", "antlr", "codegen",
54
+ "compiler", "ir", "bytecode", "semantic", "typecheck", "rewriter",
55
+ "planner", "optimizer", "expression"}),
56
+ "protocol": frozenset({"protocol", "bolt", "netty", "transport", "wire", "packstream",
57
+ "rpc", "grpc", "http", "connector", "connection"}),
58
+ "distributed": frozenset({"cluster", "raft", "discovery", "gossip", "consensus",
59
+ "replication", "coordination", "membership", "fabric", "routing"}),
60
+ "web": frozenset({"controller", "controllers", "rest", "mvc", "servlet",
61
+ "endpoint", "endpoints", "resource", "webapp", "jaxrs"}),
62
+ "service": frozenset({"service", "services", "usecase", "usecases", "application"}),
63
+ "repository": frozenset({"repository", "repositories", "repo", "dao", "persistence"}),
64
+ "event": frozenset({"event", "events", "listener", "listeners", "publisher",
65
+ "subscriber", "bus", "dispatcher", "reactor"}),
66
+ "plugin": frozenset({"plugin", "plugins", "extension", "extensions", "spi",
67
+ "provider", "providers", "addon", "module"}),
68
+ "api_pkg": frozenset({"api", "apis", "spi", "contract", "interfaces"}),
69
+ "cli": frozenset({"cli", "command", "commands", "commandline", "shell", "console"}),
70
+ "hexagonal": frozenset({"adapter", "adapters", "port", "ports"}),
71
+ "clean": frozenset({"domain", "domains", "usecase", "usecases", "infrastructure", "infra"}),
72
+ "cqrs": frozenset({"command", "commands", "query", "queries"}),
73
+ "sdk_client": frozenset({"client", "clients", "sdk"}),
74
+ }
75
+
76
+ _CONF_ORDER = {"high": 3, "medium": 2, "low": 1}
77
+ # A dimension whose best candidate scores below this is reported as undetermined
78
+ # (primary=None) rather than asserting a label on noise.
79
+ _MIN_PRIMARY_SCORE = 0.05
80
+ # A winner must clear this absolute score (not just the top-2 margin) to earn
81
+ # medium/high confidence — a near-floor lone candidate stays "low".
82
+ _MIN_CONFIDENT_SCORE = 0.5
83
+
84
+
85
+ @dataclass
86
+ class Evidence:
87
+ signal: str # machine name, e.g. "storage_mass"
88
+ detail: str # human-readable
89
+ weight: float # base importance of the signal
90
+ strength: float # 0..1 — how strongly the signal is present
91
+ mass: float # 0..1 — fraction of the codebase it represents (1.0 if N/A)
92
+ contribution: float # weight * strength * mass (what actually adds to the score)
93
+
94
+
95
+ @dataclass
96
+ class ArchetypeCandidate:
97
+ label: str
98
+ score: float
99
+ evidence: list[Evidence] = field(default_factory=list)
100
+
101
+
102
+ @dataclass
103
+ class DimensionResult:
104
+ dimension: str
105
+ primary: Optional[str]
106
+ confidence: str # high | medium | low
107
+ candidates: list[ArchetypeCandidate] # sorted score desc
108
+ explanation: str
109
+
110
+
111
+ @dataclass
112
+ class ArchetypeAnalysis:
113
+ dimensions: dict[str, DimensionResult]
114
+ signals_used: list[str]
115
+ signals_missing: list[str]
116
+ generated_from: str
117
+ graph_metrics: dict[str, float] = field(default_factory=dict)
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return {
121
+ "schema_version": "0.1-experimental",
122
+ "note": "Parallel/experimental model. Legacy architecture pattern is unchanged.",
123
+ "dimensions": {
124
+ dim: {
125
+ "primary": r.primary,
126
+ "confidence": r.confidence,
127
+ "explanation": r.explanation,
128
+ "candidates": [
129
+ {
130
+ "label": c.label,
131
+ "score": round(c.score, 4),
132
+ "evidence": [
133
+ {
134
+ "signal": e.signal, "detail": e.detail,
135
+ "weight": e.weight, "strength": round(e.strength, 3),
136
+ "mass": round(e.mass, 4),
137
+ "contribution": round(e.contribution, 4),
138
+ }
139
+ for e in c.evidence
140
+ ],
141
+ }
142
+ for c in r.candidates
143
+ ],
144
+ }
145
+ for dim, r in self.dimensions.items()
146
+ },
147
+ "signals_used": self.signals_used,
148
+ "signals_missing": self.signals_missing,
149
+ "generated_from": self.generated_from,
150
+ "graph_metrics": {k: round(v, 4) for k, v in self.graph_metrics.items()},
151
+ }
152
+
153
+
154
+ @dataclass
155
+ class _Features:
156
+ total_files: int
157
+ module_counts: dict[str, int]
158
+ dominant_module: Optional[str]
159
+ dominant_share: float
160
+ module_count: int
161
+ concept_mass: dict[str, float] # concept → fraction of source files
162
+ entry_kinds: dict[str, int]
163
+ endpoint_total: int
164
+ endpoint_share: float # endpoints per source file
165
+ packaging: Optional[str]
166
+ frameworks: set[str]
167
+ graph_hubs: list[str]
168
+ graph_cycles: int
169
+ graph_available: bool
170
+ graph_evidence: list[GraphEvidence]
171
+ graph_metrics: dict[str, float]
172
+ signals_used: list[str]
173
+ signals_missing: list[str]
174
+
175
+
176
+ # Which archetype each topological metric supports, and how. Each entry is
177
+ # (dimension, label, weight, polarity, lo, hi): the metric's raw value is ramped
178
+ # to a strength via (value-lo)/(hi-lo) clamped to [0,1]; polarity +1 supports the
179
+ # label, -1 refutes it (negative contribution). This is the ONLY place that knows
180
+ # both graphs and archetypes — the provider stays archetype-agnostic, the scorer
181
+ # stays graph-agnostic, and the coupling lives here as declarative data.
182
+ _GRAPH_MAP: dict[str, list[tuple[str, str, float, int, float, float]]] = {
183
+ "scc_mass": [
184
+ ("software_archetype", "engine", 2.5, +1, 0.10, 0.50),
185
+ ("architectural_style", "layered", 1.5, -1, 0.10, 0.50),
186
+ ],
187
+ "cyclic_density": [
188
+ ("software_archetype", "engine", 2.0, +1, 0.05, 0.40),
189
+ ("architectural_style", "layered", 2.0, -1, 0.05, 0.40),
190
+ ("architectural_style", "hexagonal", 1.5, -1, 0.05, 0.40),
191
+ ("architectural_style", "microkernel", 1.5, -1, 0.05, 0.40),
192
+ ],
193
+ "hub_concentration": [
194
+ ("software_archetype", "engine", 1.5, +1, 0.10, 0.40),
195
+ ("architectural_style", "microkernel", 1.5, +1, 0.10, 0.40),
196
+ ],
197
+ "module_centralization": [
198
+ ("architectural_style", "microkernel", 2.0, +1, 0.30, 0.80),
199
+ ("architectural_style", "hexagonal", 1.5, -1, 0.30, 0.80), # a single hub is not a distributed domain core
200
+ ("software_archetype", "platform", 0.8, +1, 0.30, 0.80),
201
+ ],
202
+ "downward_edge_ratio": [
203
+ ("architectural_style", "layered", 2.5, +1, 0.55, 0.90),
204
+ ("architectural_style", "hexagonal", 1.0, +1, 0.55, 0.90),
205
+ ],
206
+ "dependency_inversion_ratio": [
207
+ ("architectural_style", "hexagonal", 2.5, +1, 0.50, 0.80),
208
+ ("architectural_style", "clean", 1.5, +1, 0.50, 0.80),
209
+ ],
210
+ "fan_in_gini": [
211
+ ("software_archetype", "engine", 1.5, +1, 0.40, 0.75),
212
+ ],
213
+ }
214
+
215
+
216
+ class ArchetypeClassifier:
217
+ """Consumes a SourceMap; emits an ArchetypeAnalysis. Pure (no filesystem writes)."""
218
+
219
+ def analyze(
220
+ self,
221
+ sm: SourceMap,
222
+ root: Optional[Path] = None,
223
+ module_graph: object = None,
224
+ ) -> ArchetypeAnalysis:
225
+ f = self._extract_features(sm, root, module_graph)
226
+ dims = {
227
+ "software_archetype": self._software_archetype(f),
228
+ "architectural_style": self._architectural_style(f),
229
+ "deployment_shape": self._deployment_shape(f),
230
+ "primary_interface": self._primary_interface(f),
231
+ }
232
+ return ArchetypeAnalysis(
233
+ dimensions=dims,
234
+ signals_used=f.signals_used,
235
+ signals_missing=f.signals_missing,
236
+ generated_from=(
237
+ f"{f.total_files} source files, {f.module_count} modules, "
238
+ f"{f.endpoint_total} endpoints"
239
+ ),
240
+ graph_metrics=f.graph_metrics,
241
+ )
242
+
243
+ # ── feature extraction ────────────────────────────────────────────────────
244
+ def _extract_features(
245
+ self, sm: SourceMap, root: Optional[Path], module_graph: object = None,
246
+ ) -> _Features:
247
+ used: list[str] = []
248
+ missing: list[str] = []
249
+ paths = [p.replace("\\", "/") for p in (sm.file_paths or flatten_file_tree(sm.file_tree))]
250
+ src = [
251
+ p for p in paths
252
+ if p.endswith(_CODE_EXTS)
253
+ and not any(seg.lower() in _NON_SOURCE for seg in p.split("/")[:-1])
254
+ ]
255
+ total = len(src)
256
+
257
+ module_counts: dict[str, int] = {}
258
+ for p in src:
259
+ module_counts[self._module_of(p)] = module_counts.get(self._module_of(p), 0) + 1
260
+ module_counts.pop("", None)
261
+ dominant = max(module_counts, key=lambda m: module_counts[m], default=None)
262
+ dominant_share = (module_counts[dominant] / total) if (dominant and total) else 0.0
263
+ if module_counts:
264
+ used.append("module_masses")
265
+
266
+ # concept masses (share of source files whose path touches the concept)
267
+ concept_mass: dict[str, float] = {}
268
+ for concept, kws in _CONCEPTS.items():
269
+ hits = sum(
270
+ 1 for p in src
271
+ if any(seg.lower() in kws for seg in p.split("/")[:-1])
272
+ )
273
+ concept_mass[concept] = (hits / total) if total else 0.0
274
+ if total:
275
+ used.append("concept_masses")
276
+
277
+ entry_kinds: dict[str, int] = {}
278
+ for ep in sm.entry_points or []:
279
+ k = getattr(ep, "kind", None) or "unknown"
280
+ entry_kinds[k] = entry_kinds.get(k, 0) + 1
281
+ if sm.entry_points:
282
+ used.append("entry_points")
283
+ else:
284
+ missing.append("entry_points")
285
+
286
+ endpoint_total = self._endpoint_count(root)
287
+ endpoint_share = (endpoint_total / total) if total else 0.0
288
+ if root is not None:
289
+ used.append("endpoints")
290
+ else:
291
+ missing.append("endpoints")
292
+
293
+ frameworks = {fw.name for s in (sm.stacks or []) for fw in s.frameworks}
294
+ if frameworks:
295
+ used.append("frameworks")
296
+
297
+ hubs: list[str] = []
298
+ cycles = 0
299
+ mgs = sm.module_graph_summary
300
+ if mgs is not None:
301
+ hubs = list(getattr(mgs, "hubs", []) or [])
302
+ cycles = int(getattr(mgs, "cycle_count", 0) or 0)
303
+ used.append("module_graph_summary")
304
+
305
+ # Module graph as a first-class Evidence Provider (ADR-0003 step 2).
306
+ # A ModuleGraph (nodes+edges) yields topological evidence the scorer can
307
+ # consume WITHOUT ever walking the graph. Absent/too-small graphs degrade
308
+ # to today's name-mass-only behaviour and are declared missing.
309
+ graph = module_graph if module_graph is not None else sm.module_graph
310
+ graph_result: GraphEvidenceResult = (
311
+ GraphEvidenceProvider().analyze(graph) if graph is not None
312
+ else GraphEvidenceResult(node_count=0, edge_count=0)
313
+ )
314
+ if graph_result.available:
315
+ used.append("module_graph")
316
+ if not cycles and graph_result.metric("cyclic_density") > 0:
317
+ cycles = 1 # topology confirms a cyclic core even without a summary
318
+ else:
319
+ missing.append("module_graph")
320
+ # Richer signals not wired yet — declare them so confidence reflects it.
321
+ missing.append("repo_ir_subsystem_masses")
322
+ missing.append("semantic_ir_spi_fanin")
323
+
324
+ return _Features(
325
+ total_files=total,
326
+ module_counts=module_counts,
327
+ dominant_module=dominant,
328
+ dominant_share=dominant_share,
329
+ module_count=len(module_counts),
330
+ concept_mass=concept_mass,
331
+ entry_kinds=entry_kinds,
332
+ endpoint_total=endpoint_total,
333
+ endpoint_share=endpoint_share,
334
+ packaging=sm.packaging,
335
+ frameworks=frameworks,
336
+ graph_hubs=hubs,
337
+ graph_cycles=cycles,
338
+ graph_available=graph_result.available,
339
+ graph_evidence=graph_result.evidence,
340
+ graph_metrics=graph_result.metrics,
341
+ signals_used=used,
342
+ signals_missing=missing,
343
+ )
344
+
345
+ # ── dimension scorers ─────────────────────────────────────────────────────
346
+ def _software_archetype(self, f: _Features) -> DimensionResult:
347
+ cm = f.concept_mass
348
+ cands: dict[str, list[Evidence]] = {a: [] for a in
349
+ ("engine", "compiler", "framework", "library",
350
+ "application", "platform", "sdk")}
351
+
352
+ def add(label: str, signal: str, detail: str, weight: float, strength: float, mass: float):
353
+ cands[label].append(Evidence(signal, detail, weight, strength,
354
+ mass, weight * strength * mass))
355
+
356
+ # engine: storage/kernel mass is the core of the codebase
357
+ eng_mass = min(1.0, cm["storage"] + cm["kernel"])
358
+ add("engine", "storage_kernel_mass",
359
+ f"storage+kernel packages ≈ {eng_mass:.0%} of source", 3.0, 1.0, eng_mass)
360
+ if f.graph_cycles > 0:
361
+ add("engine", "cyclic_core",
362
+ f"{f.graph_cycles} import cycle(s) — tightly-woven engine core", 1.0, 1.0, 0.5)
363
+ # compiler: parser/ast/ir/codegen pipeline mass
364
+ add("compiler", "compiler_pipeline_mass",
365
+ f"parser/ast/ir/codegen packages ≈ {cm['compiler']:.0%}", 3.0, 1.0, cm["compiler"])
366
+ # framework: plugin/SPI extension surface + jar packaging + not app-entry
367
+ fw_strength = 1.0 if f.packaging == "jar" else 0.6
368
+ add("framework", "extension_surface_mass",
369
+ f"plugin/SPI/provider packages ≈ {cm['plugin']:.0%}", 2.5, fw_strength, cm["plugin"])
370
+ # library/SDK: public API surface, no server/cli bootstrap
371
+ has_server = any(k in f.entry_kinds for k in ("server", "jax_rs_controller"))
372
+ has_bootstrap = "bootstrap" in f.entry_kinds or "application" in f.entry_kinds
373
+ lib_strength = 0.4 if (has_server or has_bootstrap) else 1.0
374
+ add("library", "public_api_no_runtime",
375
+ f"api/contract packages ≈ {cm['api_pkg']:.0%}; "
376
+ f"{'no' if lib_strength == 1.0 else 'has'} server/bootstrap entry",
377
+ 2.0, lib_strength, max(cm["api_pkg"], 0.15))
378
+ if cm["sdk_client"] > 0.1:
379
+ add("sdk", "client_packages",
380
+ f"client/sdk packages ≈ {cm['sdk_client']:.0%}", 2.0, 1.0, cm["sdk_client"])
381
+ # application: has bootstrap/server + business (service) mass
382
+ if has_bootstrap or has_server:
383
+ add("application", "runtime_entry_plus_services",
384
+ f"bootstrap/server entry + service mass ≈ {cm['service']:.0%}",
385
+ 2.0, 1.0, max(cm["service"], 0.2))
386
+ # platform: many modules + multiple concept clusters + distribution
387
+ multi = 1.0 if f.module_count >= 8 else f.module_count / 8
388
+ add("platform", "many_modules_multi_concern",
389
+ f"{f.module_count} modules; distributed mass ≈ {cm['distributed']:.0%}",
390
+ 1.5, multi, max(cm["distributed"], 0.1))
391
+ self._apply_graph_evidence("software_archetype", cands, f)
392
+ return self._resolve("software_archetype", cands, f)
393
+
394
+ def _architectural_style(self, f: _Features) -> DimensionResult:
395
+ cm = f.concept_mass
396
+ cands: dict[str, list[Evidence]] = {a: [] for a in
397
+ ("layered", "hexagonal", "clean", "mvc",
398
+ "event-driven", "plugin-based", "microkernel")}
399
+
400
+ def add(label, signal, detail, weight, strength, mass):
401
+ cands[label].append(Evidence(signal, detail, weight, strength,
402
+ mass, weight * strength * mass))
403
+
404
+ # layered: needs BOTH web/controller AND service AND repository mass — and each
405
+ # must be non-trivial. Legacy asserted this from mere name presence; we weight
406
+ # by mass so a 0.3% `api/` dir does not manufacture a "controller layer".
407
+ layered_present = sum(1 for c in ("web", "service", "repository") if cm[c] >= 0.05)
408
+ layered_mass = min(1.0, cm["web"] + cm["service"] + cm["repository"])
409
+ add("layered", "layered_triplet_mass",
410
+ f"controller/service/repository mass ≈ {layered_mass:.0%} "
411
+ f"({layered_present}/3 layers above 5%)",
412
+ 2.0, layered_present / 3, layered_mass)
413
+ # mvc requires a view/web surface with real mass
414
+ add("mvc", "web_view_mass", f"web/controller mass ≈ {cm['web']:.0%}",
415
+ 2.0, 1.0 if cm["web"] >= 0.05 else 0.0, cm["web"])
416
+ # hexagonal / clean
417
+ add("hexagonal", "ports_adapters_mass", f"ports/adapters mass ≈ {cm['hexagonal']:.0%}",
418
+ 2.5, 1.0, cm["hexagonal"])
419
+ add("clean", "domain_usecase_infra_mass", f"domain/usecase/infra mass ≈ {cm['clean']:.0%}",
420
+ 2.5, 1.0, cm["clean"])
421
+ # event-driven
422
+ add("event-driven", "event_mass", f"event/listener/bus mass ≈ {cm['event']:.0%}",
423
+ 2.0, 1.0, cm["event"])
424
+ # plugin-based / microkernel: plugin mass + small dominant core
425
+ add("plugin-based", "plugin_mass", f"plugin/extension mass ≈ {cm['plugin']:.0%}",
426
+ 2.0, 1.0, cm["plugin"])
427
+ micro_strength = 1.0 if (cm["plugin"] > 0.05 and f.dominant_share < 0.4) else 0.0
428
+ add("microkernel", "small_core_many_plugins",
429
+ f"plugin mass ≈ {cm['plugin']:.0%}, dominant module {f.dominant_share:.0%}",
430
+ 1.5, micro_strength, max(cm["plugin"], 0.1))
431
+ self._apply_graph_evidence("architectural_style", cands, f)
432
+ return self._resolve("architectural_style", cands, f)
433
+
434
+ def _deployment_shape(self, f: _Features) -> DimensionResult:
435
+ cm = f.concept_mass
436
+ cands: dict[str, list[Evidence]] = {a: [] for a in
437
+ ("monolith", "multi-module", "distributed", "embedded")}
438
+
439
+ def add(label, signal, detail, weight, strength, mass):
440
+ cands[label].append(Evidence(signal, detail, weight, strength,
441
+ mass, weight * strength * mass))
442
+
443
+ multi = 1.0 if f.module_count >= 3 else f.module_count / 3
444
+ add("multi-module", "module_count", f"{f.module_count} source modules", 2.0, multi, 1.0)
445
+ # monolith: modules but a single deployable (one war, or a single dominant module)
446
+ mono_strength = 1.0 if (f.packaging == "war" or f.dominant_share >= 0.5) else 0.3
447
+ add("monolith", "single_deployable",
448
+ f"packaging={f.packaging}, dominant module {f.dominant_share:.0%}",
449
+ 1.5, mono_strength, 1.0)
450
+ add("distributed", "cluster_mass", f"cluster/raft/discovery mass ≈ {cm['distributed']:.0%}",
451
+ 2.5, 1.0, cm["distributed"])
452
+ # embedded: library packaging + embeddable API, no server bootstrap
453
+ emb_strength = 1.0 if (f.packaging == "jar" and "server" not in f.entry_kinds) else 0.3
454
+ add("embedded", "library_packaging_no_server",
455
+ f"packaging={f.packaging}, {'no' if 'server' not in f.entry_kinds else 'has'} server entry",
456
+ 1.5, emb_strength, 0.5)
457
+ return self._resolve("deployment_shape", cands, f)
458
+
459
+ def _primary_interface(self, f: _Features) -> DimensionResult:
460
+ cm = f.concept_mass
461
+ cands: dict[str, list[Evidence]] = {a: [] for a in
462
+ ("api-first", "cli", "worker", "daemon",
463
+ "library", "embedded")}
464
+
465
+ def add(label, signal, detail, weight, strength, mass):
466
+ cands[label].append(Evidence(signal, detail, weight, strength,
467
+ mass, weight * strength * mass))
468
+
469
+ # api-first: HTTP surface must be significant relative to size. Ramp so a
470
+ # rounding-error surface cannot win: 0 below 0.5 endpoints/100 files, linear
471
+ # to full strength at >=2/100 files. (neo4j: 18/5600 = 0.32/100 -> 0.)
472
+ density = f.endpoint_share * 100
473
+ api_strength = max(0.0, min(1.0, (density - 0.5) / 1.5)) if f.endpoint_total else 0.0
474
+ add("api-first", "endpoint_density",
475
+ f"{f.endpoint_total} endpoints over {f.total_files} files "
476
+ f"({density:.2f}/100 files)",
477
+ 3.0, api_strength, 1.0)
478
+ cli_entries = f.entry_kinds.get("cli", 0)
479
+ add("cli", "cli_entry_points", f"{cli_entries} CLI entry point(s); cli mass ≈ {cm['cli']:.0%}",
480
+ 2.5, 1.0 if cli_entries else (1.0 if cm["cli"] > 0.05 else 0.0), max(cm["cli"], 0.2))
481
+ add("daemon", "server_bootstrap",
482
+ f"server/bootstrap entries: {f.entry_kinds.get('server',0)+f.entry_kinds.get('bootstrap',0)}",
483
+ 2.0, 1.0 if ("server" in f.entry_kinds or "bootstrap" in f.entry_kinds) else 0.0, 0.5)
484
+ add("worker", "event_no_http",
485
+ f"event mass ≈ {cm['event']:.0%}, endpoints={f.endpoint_total}",
486
+ 1.5, 1.0 if (cm["event"] > 0.05 and f.endpoint_total == 0) else 0.0, max(cm["event"], 0.1))
487
+ lib_strength = 1.0 if (f.packaging == "jar" and not f.entry_kinds) else 0.3
488
+ add("library", "consumed_as_dependency",
489
+ f"packaging={f.packaging}, entry kinds={list(f.entry_kinds) or 'none'}",
490
+ 1.5, lib_strength, 0.4)
491
+ add("embedded", "embeddable_api", f"api mass ≈ {cm['api_pkg']:.0%}, packaging={f.packaging}",
492
+ 1.2, 1.0 if (f.packaging == "jar" and cm["api_pkg"] > 0.03) else 0.0, 0.4)
493
+ return self._resolve("primary_interface", cands, f)
494
+
495
+ # ── graph evidence bridge ──────────────────────────────────────────────────
496
+ def _apply_graph_evidence(
497
+ self, dimension: str, cands: dict[str, list[Evidence]], f: _Features,
498
+ ) -> None:
499
+ """Translate topological GraphEvidence into archetype Evidence for this
500
+ dimension, via the declarative `_GRAPH_MAP`. The scorer consumes evidence
501
+ objects here; it never inspects nodes or edges. A metric with polarity -1
502
+ contributes NEGATIVELY (e.g. a dense cyclic core refutes 'layered')."""
503
+ if not f.graph_available:
504
+ return
505
+ ev_by_kind = {e.kind: e for e in f.graph_evidence}
506
+ for kind, mappings in _GRAPH_MAP.items():
507
+ ge = ev_by_kind.get(kind)
508
+ if ge is None:
509
+ continue
510
+ for dim, label, weight, polarity, lo, hi in mappings:
511
+ if dim != dimension or label not in cands:
512
+ continue
513
+ span = (hi - lo) or 1.0
514
+ strength = max(0.0, min(1.0, (ge.value - lo) / span))
515
+ if strength <= 0.0:
516
+ continue
517
+ contribution = weight * strength * ge.mass * polarity
518
+ sign = "" if polarity > 0 else "_refutes"
519
+ cands[label].append(Evidence(
520
+ signal=f"graph_{kind}{sign}",
521
+ detail=ge.detail,
522
+ weight=weight, strength=strength, mass=ge.mass,
523
+ contribution=contribution,
524
+ ))
525
+
526
+ # ── conflict resolution + confidence ──────────────────────────────────────
527
+ def _resolve(self, dimension: str, raw: dict[str, list[Evidence]], f: _Features) -> DimensionResult:
528
+ candidates = [
529
+ ArchetypeCandidate(label=lbl, score=sum(e.contribution for e in evs), evidence=evs)
530
+ for lbl, evs in raw.items()
531
+ ]
532
+ candidates.sort(key=lambda c: c.score, reverse=True)
533
+ top = candidates[0] if candidates else None
534
+ runner = candidates[1] if len(candidates) > 1 else None
535
+ # Floor: do not declare a winner on negligible evidence (e.g. a 0.3% plugin
536
+ # dir "winning" architectural_style). Below it, the dimension is undetermined.
537
+ primary = top.label if top and top.score >= _MIN_PRIMARY_SCORE else None
538
+
539
+ # Confidence from margin between top-2 + whether mass/graph evidence backs it
540
+ # (vs name-only), plus penalties for missing richer signals.
541
+ confidence = "low"
542
+ if top and top.score > 0:
543
+ margin = top.score - (runner.score if runner else 0.0)
544
+ rel_margin = margin / top.score if top.score else 0.0
545
+ mass_backed = any(e.mass < 1.0 and e.contribution > 0 for e in top.evidence)
546
+ # Graph-backed = the WINNING conclusion is supported by real topology,
547
+ # not merely that a graph exists. Ties "high" confidence to structural
548
+ # evidence for THIS label (ADR-0003 acceptance criterion).
549
+ graph_backed = any(
550
+ e.signal.startswith("graph_") and e.contribution > 0 for e in top.evidence
551
+ )
552
+ # Elevated confidence also requires a non-marginal absolute score: a
553
+ # winner barely above the evidence floor is "undetermined-ish" and must
554
+ # not read as high/medium however lonely it is.
555
+ confident_enough = top.score >= _MIN_CONFIDENT_SCORE
556
+ if confident_enough and rel_margin >= 0.4 and mass_backed and graph_backed:
557
+ confidence = "high"
558
+ elif confident_enough and rel_margin >= 0.25 and mass_backed:
559
+ confidence = "medium"
560
+ else:
561
+ confidence = "low"
562
+
563
+ if primary is None:
564
+ explanation = (
565
+ f"No dominant {dimension.replace('_', ' ')}: best candidate "
566
+ f"'{top.label}' ({top.score:.3f}) is below the evidence floor "
567
+ f"({_MIN_PRIMARY_SCORE}) — undetermined."
568
+ ) if top else f"No evidence for {dimension.replace('_', ' ')}."
569
+ confidence = "low" if not top or top.score == 0 else confidence
570
+ else:
571
+ explanation = self._explain(dimension, top, runner)
572
+ return DimensionResult(dimension, primary, confidence, candidates, explanation)
573
+
574
+ def _explain(self, dimension: str, top: Optional[ArchetypeCandidate],
575
+ runner: Optional[ArchetypeCandidate]) -> str:
576
+ if top is None or top.score <= 0:
577
+ return f"No archetype evidence for {dimension}."
578
+ parts = [f"{e.signal}(+{e.contribution:.3f})"
579
+ for e in sorted(top.evidence, key=lambda e: e.contribution, reverse=True)
580
+ if e.contribution > 0][:3]
581
+ base = f"'{top.label}' wins ({top.score:.3f}) on {', '.join(parts) or 'weak signals'}"
582
+ if runner and runner.score > 0:
583
+ base += f"; over '{runner.label}' ({runner.score:.3f})"
584
+ return base + "."
585
+
586
+ # ── helpers ───────────────────────────────────────────────────────────────
587
+ @staticmethod
588
+ def _module_of(path: str) -> str:
589
+ norm = path.replace("\\", "/")
590
+ idx = norm.find("/src/")
591
+ if idx > 0:
592
+ return norm[:idx]
593
+ head, _, tail = norm.partition("/")
594
+ return head if tail else ""
595
+
596
+ @staticmethod
597
+ def _endpoint_count(root: Optional[Path]) -> int:
598
+ if root is None:
599
+ return 0
600
+ try:
601
+ from sourcecode.repository_ir import extract_java_endpoints
602
+ data = extract_java_endpoints(root)
603
+ return int(data.get("total", len(data.get("endpoints", []))))
604
+ except Exception:
605
+ return 0