livepilot 1.24.0 → 1.25.0

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,554 @@
1
+ """AtlasResolver — v1.25 hybrid knowledge surface.
2
+
3
+ Three layers of corpus access for the agent:
4
+
5
+ Layer A — resolve_anchors(): cohort + role-anchored URIs at brief-build time.
6
+ Wraps existing atlas_pack_aware_compose; cheap
7
+ (~400 tokens in the brief).
8
+ Layer B — resolve_for_role(): per-role ranked candidates with cohort constraint.
9
+ Called by atlas_explore MCP tool during plan design,
10
+ and as a fallback when an anchor doesn't fit.
11
+ Layer C — _score(): corpus-deep ranking signals: tag base, signature
12
+ technique mood overlap, curated .adg presence,
13
+ taste profile, anti-repeat, §1 banned defaults,
14
+ pad-opaque-M4L penalty.
15
+
16
+ Designed to be both invoked from KnowledgePack (brief-build time) AND from the
17
+ three new MCP tools (atlas_explore / atlas_audition / atlas_substitute) at
18
+ plan-design time. Same ranking logic, same candidate shape.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Optional
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # ── Constants (memory rules §1, §7 #2) ──────────────────────────────
31
+
32
+ # §1: Banned as defaults for melodic roles unless mood says "analog".
33
+ # Mirrors `_BANNED_DEFAULT_DEVICES` in mcp_server/composer/fast/brief_builder.py
34
+ # so the two paths agree.
35
+ BANNED_DEFAULT_MELODIC: frozenset[str] = frozenset({"Analog", "Poli", "Drift", "Meld"})
36
+
37
+ # §1 pad-specific: opaque to the sound-design critic. Stacks with the banned-
38
+ # default penalty for pad role on Poli/Meld (intentional — both rules apply).
39
+ OPAQUE_M4L_FOR_PAD: frozenset[str] = frozenset({"Poli", "Meld"})
40
+
41
+ # Brief role → atlas tag candidates (matches fast/brief_builder.py _ROLE_TAGS).
42
+ _ROLE_TAGS: dict[str, tuple[str, ...]] = {
43
+ "kick": ("kick", "drum", "808"),
44
+ "snare": ("snare", "clap", "drum"),
45
+ "hat": ("hihat", "hi-hat", "hat", "cymbal"),
46
+ "perc": ("perc", "percussion", "drum"),
47
+ "clap": ("clap", "snare"),
48
+ "bass": ("bass", "sub_bass", "sub"),
49
+ "pad": ("pad", "texture", "atmos", "ambient"),
50
+ "lead": ("lead", "synth_lead", "pluck"),
51
+ "atmos": ("atmos", "ambient", "drone", "texture"),
52
+ "vocal_chop": ("vocal", "vox", "voice"),
53
+ "fx": ("fx", "effect", "transition"),
54
+ "spectral": ("spectral", "stretch", "freeze"),
55
+ }
56
+
57
+ _MELODIC_ROLES: frozenset[str] = frozenset({"bass", "lead", "pad", "atmos", "vocal_chop"})
58
+
59
+ # pack_aware_compose's coarse roles → brief's finer roles.
60
+ # Used in resolve_anchors to spread one cohort pick across multiple fine roles.
61
+ _COARSE_TO_FINE_ROLES: dict[str, tuple[str, ...]] = {
62
+ "rhythmic-driver": ("kick", "snare", "hat"),
63
+ "bass": ("bass",),
64
+ "melodic": ("lead", "vocal_chop"),
65
+ "harmonic-foundation": ("pad",),
66
+ "wash": ("atmos",),
67
+ "fx-bus": ("fx",),
68
+ "spectral-processing": ("spectral",),
69
+ }
70
+
71
+
72
+ # ── Dataclasses ─────────────────────────────────────────────────────
73
+
74
+
75
+ @dataclass
76
+ class AtlasCandidate:
77
+ """A single device/preset candidate with full reasoning trail."""
78
+ uri: str
79
+ name: str
80
+ source: str = "atlas" # "atlas" | "extension_atlas:packs" | "extension_atlas:m4l-devices" | "browser" | "pack_aware_compose"
81
+ score: float = 0.0
82
+ character_tags: list[str] = field(default_factory=list)
83
+ signature_techniques: list[str] = field(default_factory=list)
84
+ in_pack: Optional[str] = None
85
+ has_curated_adg: bool = False
86
+ reasoning: str = ""
87
+
88
+
89
+ @dataclass
90
+ class AtlasAnchors:
91
+ """Brief-level pre-resolution. Carried in KnowledgePack output. ~400 tokens."""
92
+ primary_pack: Optional[str] = None
93
+ pack_cohort: list[str] = field(default_factory=list)
94
+ anchor_producers: list[str] = field(default_factory=list)
95
+ anchor_genres: list[str] = field(default_factory=list)
96
+ primary_aesthetic: str = ""
97
+ vocab_tags: list[str] = field(default_factory=list)
98
+ techniques_in_play: list[str] = field(default_factory=list)
99
+ cohort_uris: dict[str, str] = field(default_factory=dict) # role → URI/preset slug
100
+ reasoning: str = ""
101
+
102
+
103
+ # ── Resolver ────────────────────────────────────────────────────────
104
+
105
+
106
+ class AtlasResolver:
107
+ """Layer-aware atlas knowledge resolver for the v1.25 hybrid surface."""
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ atlas: Any,
113
+ ableton: Any = None,
114
+ taste_profile: Optional[dict] = None,
115
+ recent_uris: Optional[set[str]] = None,
116
+ ):
117
+ self._atlas = atlas
118
+ self._ableton = ableton
119
+ self._taste_profile: dict = dict(taste_profile or {})
120
+ self._recent_uris: set[str] = set(recent_uris or set())
121
+
122
+ # ── Layer A — anchors ───────────────────────────────────────
123
+
124
+ def resolve_anchors(
125
+ self,
126
+ *,
127
+ brief_text: str,
128
+ genre: str = "",
129
+ mood: str = "",
130
+ artist_refs: Optional[list[str]] = None,
131
+ ) -> AtlasAnchors:
132
+ """Pre-resolve brief-level anchors via atlas_pack_aware_compose.
133
+
134
+ Wraps the existing v1.23 cohort tool (which already does coherent
135
+ pack/producer cluster selection across artist + genre vocabularies),
136
+ then maps its 7 coarse roles onto the brief's finer 8 roles via
137
+ _COARSE_TO_FINE_ROLES. Best-effort: if pack_aware_compose errors or
138
+ is unavailable, returns an empty AtlasAnchors (anchor_producers,
139
+ cohort_uris all empty) rather than raising.
140
+ """
141
+ artist_refs = list(artist_refs or [])
142
+ try:
143
+ from ...atlas.pack_aware_compose import pack_aware_compose
144
+ except Exception as exc: # import error — corpus not installed
145
+ logger.debug("resolve_anchors: import pack_aware_compose failed: %s", exc)
146
+ return AtlasAnchors(reasoning=f"pack_aware_compose unavailable ({exc})")
147
+
148
+ try:
149
+ result = pack_aware_compose(
150
+ aesthetic_brief=brief_text or "",
151
+ target_bpm=None,
152
+ target_scale="",
153
+ track_count=8,
154
+ pack_diversity="coherent",
155
+ )
156
+ except Exception as exc:
157
+ logger.debug("resolve_anchors: pack_aware_compose raised: %s", exc)
158
+ return AtlasAnchors(reasoning=f"pack_aware_compose raised ({exc})")
159
+
160
+ if not isinstance(result, dict) or result.get("error") or result.get("status") == "error":
161
+ err = (result or {}).get("error") if isinstance(result, dict) else "no result"
162
+ return AtlasAnchors(reasoning=f"pack_aware_compose error: {err}")
163
+
164
+ ba = result.get("brief_analysis") or {}
165
+ cohort = list(ba.get("pack_cohort") or result.get("pack_cohort") or [])
166
+ cohort_uris = self._map_pack_aware_roles(result.get("track_proposal") or [])
167
+ techniques = self._extract_techniques_for_packs(cohort)
168
+
169
+ # vocab_tags = anchor producers + genres + mood + primary aesthetic.
170
+ # De-dup while preserving order so the highest-priority tag stays first.
171
+ vocab: list[str] = []
172
+ seen: set[str] = set()
173
+ for v in (
174
+ [ba.get("primary_aesthetic", "") or ""]
175
+ + list(ba.get("secondary_aesthetics") or [])
176
+ + artist_refs
177
+ + ([mood] if mood else [])
178
+ ):
179
+ v = (v or "").strip()
180
+ if v and v.lower() not in seen:
181
+ seen.add(v.lower())
182
+ vocab.append(v)
183
+
184
+ return AtlasAnchors(
185
+ primary_pack=cohort[0] if cohort else None,
186
+ pack_cohort=cohort,
187
+ anchor_producers=list(ba.get("anchor_producers") or []),
188
+ anchor_genres=list(ba.get("anchor_genres") or []),
189
+ primary_aesthetic=ba.get("primary_aesthetic") or "",
190
+ vocab_tags=vocab,
191
+ techniques_in_play=techniques,
192
+ cohort_uris=cohort_uris,
193
+ reasoning=self._anchors_reasoning(ba, cohort, mood),
194
+ )
195
+
196
+ @staticmethod
197
+ def _map_pack_aware_roles(track_proposal: list[dict]) -> dict[str, str]:
198
+ """pack_aware_compose's coarse roles → brief's fine roles.
199
+
200
+ Track proposal entries carry `role` (coarse) and `preset` (slug
201
+ "pack/preset-name"). Spread the preset slug across the fine roles
202
+ the coarse role covers — the apply pass resolves the slug to a
203
+ runtime URI via search_browser.
204
+ """
205
+ cohort_uris: dict[str, str] = {}
206
+ for tp in track_proposal:
207
+ coarse = tp.get("role") or ""
208
+ preset = (tp.get("preset") or "").strip()
209
+ if not preset:
210
+ continue
211
+ for fine in _COARSE_TO_FINE_ROLES.get(coarse, ()):
212
+ cohort_uris.setdefault(fine, preset)
213
+ return cohort_uris
214
+
215
+ @staticmethod
216
+ def _extract_techniques_for_packs(cohort: list[str]) -> list[str]:
217
+ """Pull signature_techniques cross-referenced for cohort packs.
218
+
219
+ v1.25 skeleton — returns []. Wired in v1.25.x once
220
+ device_techniques_index.json reverse-lookup is in place.
221
+ """
222
+ return []
223
+
224
+ @staticmethod
225
+ def _anchors_reasoning(brief_analysis: dict, cohort: list[str], mood: str) -> str:
226
+ bits: list[str] = []
227
+ aesthetic = brief_analysis.get("primary_aesthetic")
228
+ if aesthetic:
229
+ bits.append(f"primary aesthetic '{aesthetic}'")
230
+ if cohort:
231
+ bits.append(f"cohort: {', '.join(cohort[:3])}")
232
+ if mood:
233
+ bits.append(f"mood '{mood}'")
234
+ if not bits:
235
+ return "Default cohort (no aesthetic anchors detected in brief)."
236
+ return "Anchors derived from " + "; ".join(bits) + "."
237
+
238
+ # ── Layer B — per-role candidates ───────────────────────────
239
+
240
+ def resolve_for_role(
241
+ self,
242
+ *,
243
+ role: str,
244
+ genre: str = "",
245
+ mood: str = "",
246
+ artist_refs: Optional[list[str]] = None,
247
+ avoid: Optional[list[str]] = None,
248
+ cohort_constraint: Optional[list[str]] = None,
249
+ excluded_uris: Optional[set[str]] = None,
250
+ n: int = 5,
251
+ ) -> list[AtlasCandidate]:
252
+ """Resolve N ranked candidates for a single role.
253
+
254
+ Uses atlas tag indexes as primary. Deeper sources (extension_atlas,
255
+ search_browser) are deferred to v1.25.x once the surface is proven.
256
+
257
+ Returns list[AtlasCandidate] sorted by score descending, length <= n.
258
+ Empty list when atlas is None or no candidates pass filters.
259
+ """
260
+ if self._atlas is None:
261
+ return []
262
+
263
+ artist_refs = list(artist_refs or [])
264
+ avoid_list = list(avoid or [])
265
+ excluded = set(excluded_uris or set())
266
+ cohort_set = set(cohort_constraint or [])
267
+
268
+ role_lower = (role or "").lower()
269
+ tags = _ROLE_TAGS.get(role_lower, (role_lower,))
270
+
271
+ # ── Source 1: factory atlas tag index ───────────────────────
272
+ seen_uris: set[str] = set()
273
+ candidates: list[tuple[dict, str]] = [] # (device, source_label)
274
+ by_tag = getattr(self._atlas, "_by_tag", {}) or {}
275
+ for tag in tags:
276
+ for dev in by_tag.get(str(tag).lower(), []):
277
+ uri = dev.get("uri") or ""
278
+ if not uri or uri in seen_uris or uri in excluded:
279
+ continue
280
+ if cohort_set and dev.get("pack") not in cohort_set:
281
+ continue
282
+ seen_uris.add(uri)
283
+ candidates.append((dev, "atlas"))
284
+
285
+ # ── Source 2: extension_atlas overlays (user corpus + packs + m4l-devices + demos) ──
286
+ # Per user mandate: full-mode resolve_for_role MUST union with the overlay
287
+ # corpus. Producer-curated rack instruments (808 Trap Selector Rack from
288
+ # Trap Drums by Sound Oracle, Harmonic Drone Generator from Drone Lab,
289
+ # user-scanned .amxd library, etc.) live in overlays only, never in the
290
+ # factory tag index. Best-effort: import failure leaves overlay results
291
+ # empty rather than raising.
292
+ overlay_devs = self._gather_from_overlays(
293
+ role=role_lower,
294
+ tags=tags,
295
+ cohort_set=cohort_set,
296
+ excluded=excluded,
297
+ seen_uris=seen_uris,
298
+ )
299
+ for dev in overlay_devs:
300
+ candidates.append((dev, dev.get("_overlay_source") or "extension_atlas"))
301
+
302
+ scored: list[tuple[float, AtlasCandidate]] = []
303
+ for dev, source_label in candidates:
304
+ score, reasoning = self._score(
305
+ dev,
306
+ role=role_lower,
307
+ genre=genre,
308
+ mood=mood,
309
+ artist_refs=artist_refs,
310
+ avoid=avoid_list,
311
+ )
312
+ # +0.15 for demo_project entries — ground-truth role→URI mappings
313
+ # from analyzed Ableton-shipped .als demos. Per user mandate these
314
+ # are the highest-confidence per-role anchors.
315
+ if source_label == "extension_atlas:demo_project":
316
+ score += 0.15
317
+ reasoning = f"{reasoning}; demo_project ground-truth (+0.15)"
318
+ scored.append((score, self._to_candidate(dev, score, reasoning, source=source_label)))
319
+
320
+ scored.sort(key=lambda x: -x[0])
321
+ return [c for _, c in scored[:n]]
322
+
323
+ def _gather_from_overlays(
324
+ self,
325
+ *,
326
+ role: str,
327
+ tags: tuple[str, ...],
328
+ cohort_set: set[str],
329
+ excluded: set[str],
330
+ seen_uris: set[str],
331
+ ) -> list[dict]:
332
+ """Pull role-matching candidates from extension_atlas overlay namespaces.
333
+
334
+ Searches THREE overlay surfaces:
335
+ 1. `packs` namespace, entity_type="demo_project" — ground-truth role→URI
336
+ mappings extracted from analyzed factory pack .als demos. Highest
337
+ confidence per-role anchor source. Tagged "extension_atlas:demo_project".
338
+ 2. `packs` / `m4l-devices` / `elektron` / `user.*` namespaces, all
339
+ entity_types — surfaces curated rack instruments and user-scanned
340
+ devices not in the factory tag index. Tagged "extension_atlas".
341
+ 3. Per-tag substring search per role (e.g., "808" tag for kick/bass)
342
+ so producer-curated instruments with role-bearing tags are surfaced.
343
+
344
+ Returns list[dict] in atlas-candidate-shape (uri, name, character_tags,
345
+ signature_techniques, pack=namespace, _overlay_source).
346
+ """
347
+ try:
348
+ from ...atlas.overlays import get_overlay_index
349
+ except Exception as exc: # overlay backend unavailable
350
+ logger.debug("_gather_from_overlays: overlay import failed: %s", exc)
351
+ return []
352
+
353
+ try:
354
+ idx = get_overlay_index()
355
+ except Exception as exc:
356
+ logger.debug("_gather_from_overlays: get_overlay_index failed: %s", exc)
357
+ return []
358
+
359
+ results: list[dict] = []
360
+
361
+ # Pass A — demo_project ground truth (limit 10 per tag, dedup downstream).
362
+ for tag in tags:
363
+ try:
364
+ matches = idx.search(
365
+ str(tag).lower(),
366
+ namespace="packs",
367
+ entity_type="demo_project",
368
+ limit=10,
369
+ )
370
+ except Exception:
371
+ continue
372
+ for entry in matches:
373
+ dev = self._overlay_entry_to_device(entry, source="extension_atlas:demo_project")
374
+ if not dev:
375
+ continue
376
+ uri = dev.get("uri") or ""
377
+ if uri and uri in seen_uris or uri in excluded:
378
+ continue
379
+ if cohort_set and dev.get("pack") not in cohort_set:
380
+ continue
381
+ if uri:
382
+ seen_uris.add(uri)
383
+ results.append(dev)
384
+
385
+ # Pass B — full overlay search across all namespaces and entity_types.
386
+ for tag in tags:
387
+ try:
388
+ matches = idx.search(str(tag).lower(), limit=10)
389
+ except Exception:
390
+ continue
391
+ for entry in matches:
392
+ # Skip duplicate hits already covered by Pass A.
393
+ if getattr(entry, "entity_type", "") == "demo_project":
394
+ continue
395
+ dev = self._overlay_entry_to_device(entry, source="extension_atlas")
396
+ if not dev:
397
+ continue
398
+ uri = dev.get("uri") or ""
399
+ if uri and uri in seen_uris or uri in excluded:
400
+ continue
401
+ if cohort_set and dev.get("pack") not in cohort_set:
402
+ continue
403
+ if uri:
404
+ seen_uris.add(uri)
405
+ results.append(dev)
406
+
407
+ return results
408
+
409
+ @staticmethod
410
+ def _overlay_entry_to_device(entry: Any, *, source: str) -> Optional[dict]:
411
+ """Map an OverlayEntry to the atlas-candidate dict shape.
412
+
413
+ Overlay entries don't carry a load-able URI in the same shape as the
414
+ factory atlas; we synthesize one as `overlay://<namespace>/<entity_id>`
415
+ so the agent can still call extension_atlas_get(namespace, entity_id)
416
+ to resolve the actual loadable resource. The character_tags field
417
+ carries the overlay's tags so _score's mood-overlap heuristic still
418
+ fires.
419
+ """
420
+ try:
421
+ namespace = getattr(entry, "namespace", "") or ""
422
+ entity_id = getattr(entry, "entity_id", "") or ""
423
+ if not namespace or not entity_id:
424
+ return None
425
+ return {
426
+ "uri": f"overlay://{namespace}/{entity_id}",
427
+ "name": getattr(entry, "name", "") or entity_id,
428
+ "character_tags": list(getattr(entry, "tags", []) or []),
429
+ "signature_techniques": [],
430
+ "pack": namespace,
431
+ "has_curated_adg": False,
432
+ "_overlay_source": source,
433
+ "_overlay_namespace": namespace,
434
+ "_overlay_entity_id": entity_id,
435
+ }
436
+ except Exception:
437
+ return None
438
+
439
+ # ── Layer C — ranking ───────────────────────────────────────
440
+
441
+ def _score(
442
+ self,
443
+ dev: dict,
444
+ *,
445
+ role: str,
446
+ genre: str,
447
+ mood: str,
448
+ artist_refs: list[str],
449
+ avoid: list[str],
450
+ ) -> tuple[float, str]:
451
+ """Compute (score in [0, 1], reasoning string) for one candidate."""
452
+ score = 0.5 # baseline tag-match
453
+ reasons: list[str] = ["tag match"]
454
+
455
+ name = dev.get("name") or ""
456
+ sig_techs = list(dev.get("signature_techniques") or [])
457
+
458
+ mood_lower = (mood or "").lower()
459
+ genre_lower = (genre or "").lower()
460
+
461
+ # +0.20 signature_technique mood overlap (token-level)
462
+ if sig_techs and mood_lower:
463
+ mood_tokens = [tok for tok in mood_lower.split() if len(tok) > 3]
464
+ for tech in sig_techs:
465
+ tech_lower = str(tech).lower()
466
+ if any(tok in tech_lower for tok in mood_tokens):
467
+ score += 0.20
468
+ reasons.append(f"signature_technique '{tech}' matches mood")
469
+ break
470
+
471
+ # +0.10 has curated .adg sidecar
472
+ if self._has_curated_adg(dev):
473
+ score += 0.10
474
+ reasons.append("curated .adg sidecar")
475
+
476
+ # +0.10 recent positive preference
477
+ if name and self._taste_profile.get(name, {}).get("score", 0) > 0:
478
+ score += 0.10
479
+ reasons.append("recent positive preference")
480
+
481
+ # +0.10/+0.05 genre primary/secondary
482
+ if genre_lower:
483
+ primary, secondary = self._device_genres(dev)
484
+ if any(genre_lower in g for g in primary):
485
+ score += 0.10
486
+ reasons.append(f"genre primary '{genre}'")
487
+ elif any(genre_lower in g for g in secondary):
488
+ score += 0.05
489
+ reasons.append(f"genre secondary '{genre}'")
490
+
491
+ # −0.50 §1 banned default for melodic role, mood not "analog"
492
+ if role in _MELODIC_ROLES and name in BANNED_DEFAULT_MELODIC:
493
+ if "analog" not in mood_lower:
494
+ score -= 0.50
495
+ reasons.append(f"§1 banned default '{name}' (mood not 'analog')")
496
+
497
+ # −0.30 pad role with opaque M4L
498
+ if role == "pad" and name in OPAQUE_M4L_FOR_PAD:
499
+ score -= 0.30
500
+ reasons.append(f"opaque M4L pad '{name}' (sound-design critic blind)")
501
+
502
+ # −0.15 anti-repeat (recent_uris)
503
+ uri = dev.get("uri") or ""
504
+ if uri and uri in self._recent_uris:
505
+ score -= 0.15
506
+ reasons.append("recently used (§7 #2 anti-repeat)")
507
+
508
+ # −0.30 caller-supplied avoid-list
509
+ if name in avoid:
510
+ score -= 0.30
511
+ reasons.append("on caller-supplied avoid list")
512
+
513
+ score = max(0.0, min(1.0, score))
514
+ return score, "; ".join(reasons)
515
+
516
+ @staticmethod
517
+ def _device_genres(dev: dict) -> tuple[list[str], list[str]]:
518
+ """Return (primary, secondary) genre lists, lowercased, dup-tolerant."""
519
+ primary: list[str] = []
520
+ secondary: list[str] = []
521
+ for key in ("genre_affinity", "genres"):
522
+ container = dev.get(key) or {}
523
+ if not isinstance(container, dict):
524
+ continue
525
+ for g in container.get("primary", []) or []:
526
+ primary.append(str(g).lower())
527
+ for g in container.get("secondary", []) or []:
528
+ secondary.append(str(g).lower())
529
+ return primary, secondary
530
+
531
+ @staticmethod
532
+ def _has_curated_adg(dev: dict) -> bool:
533
+ """Heuristic: has curated .adg if explicit flag, .adg URI, or /adg/ path hint."""
534
+ if dev.get("has_curated_adg"):
535
+ return True
536
+ uri = (dev.get("uri") or "").lower()
537
+ if uri.endswith(".adg") or "/adg/" in uri:
538
+ return True
539
+ return False
540
+
541
+ @staticmethod
542
+ def _to_candidate(dev: dict, score: float, reasoning: str, *, source: str) -> AtlasCandidate:
543
+ char_tags = list(dev.get("character_tags") or dev.get("tags") or [])
544
+ return AtlasCandidate(
545
+ uri=dev.get("uri") or "",
546
+ name=dev.get("name") or "",
547
+ source=source,
548
+ score=score,
549
+ character_tags=char_tags,
550
+ signature_techniques=list(dev.get("signature_techniques") or []),
551
+ in_pack=dev.get("pack"),
552
+ has_curated_adg=AtlasResolver._has_curated_adg(dev),
553
+ reasoning=reasoning,
554
+ )