simulo 0.26.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.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
simulo/_client/asset.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""``Asset`` — a read-only, registry-resolved mount (implements ``AssetProtocol``).
|
|
2
|
+
|
|
3
|
+
A registry asset ref is ``[<publisher>/]<kind>/<name>[:v<N>]`` (e.g.
|
|
4
|
+
``robot/so-arm-100:v3``, ``simulo/world/warehouse:v2``, or the unpinned
|
|
5
|
+
``robot/forklift`` — resolved to an exact version **at submit**). The grammar
|
|
6
|
+
is the ONE shared parser from ``simulo.interfaces.platform.asset_catalog`` — the
|
|
7
|
+
CLI, the control plane, the worker, and the console all parse through it, so the
|
|
8
|
+
grammar can never drift. This handle carries the parsed ``kind`` (``robot`` /
|
|
9
|
+
``world`` / ``prop``), which enables **kind safety** (``Robot(asset=<world asset>)`` fails
|
|
10
|
+
with a one-line explanation naming the mismatched kind).
|
|
11
|
+
|
|
12
|
+
The on-disk :attr:`path` only exists in **execution mode**:
|
|
13
|
+
|
|
14
|
+
* On a cloud worker (or a local ``run-package``), the runner exports
|
|
15
|
+
``SIMULO_ASSET_<uri>`` pointing at the read-only mount; :attr:`path` returns it.
|
|
16
|
+
* When no runner mount is present (a local run against the on-disk catalog cache),
|
|
17
|
+
:attr:`path` resolves the asset out of ``~/.simulo/assets/<org>/<kind>/<name>/v<N>/``
|
|
18
|
+
— the exact colon-free, NTFS-safe layout ``simulo asset get`` writes into — and,
|
|
19
|
+
when the asset has not been fetched, raises with the copyable
|
|
20
|
+
``simulo asset get <ref>`` command to run.
|
|
21
|
+
|
|
22
|
+
``Asset`` also unifies the *model-asset* spec surface: ``usd(path)`` / ``urdf(path)``
|
|
23
|
+
name a robot/object source to load into a scene. In discovery (submit) these return
|
|
24
|
+
an inert, torch-free :class:`_AssetSpec` so authoring a task is lean; only the
|
|
25
|
+
backend runner (execution mode) resolves them to the real model-source
|
|
26
|
+
``simulo.interfaces.authoring.asset.AssetSource`` (the single, dependency-free
|
|
27
|
+
definition the backend consumes — relocated out of the backend in Plan C, renamed
|
|
28
|
+
from ``Asset`` by owner decision to resolve the name collision with this very class).
|
|
29
|
+
|
|
30
|
+
**Layer split.** This is the user-facing *handle* (``simulo.Asset``); the
|
|
31
|
+
asset-catalog **wire** contract (ref grammar, routes, caps, wire dataclasses) lives
|
|
32
|
+
in ``simulo.interfaces.platform.asset_catalog`` — this module consumes that
|
|
33
|
+
module's :func:`parse_asset_ref` and stays decoupled from it otherwise.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import os
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Iterator, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
from simulo._client._mounts import asset_env_key
|
|
43
|
+
from simulo._client.mode import EXECUTION, current_mode
|
|
44
|
+
from simulo.interfaces.authoring import AssetSource as _ModelSourceAsset
|
|
45
|
+
from simulo.interfaces.exceptions import ContractViolationError, ResourceNotFoundError
|
|
46
|
+
from simulo.interfaces.platform.asset_catalog import (
|
|
47
|
+
GLOBAL_ASSET_PUBLISHER,
|
|
48
|
+
RESERVED_ASSET_PUBLISHERS,
|
|
49
|
+
ParsedAssetRef,
|
|
50
|
+
format_asset_ref,
|
|
51
|
+
parse_asset_ref,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Construction capture — every `Asset.from_registry(...)` handle constructed
|
|
56
|
+
# while a capture scope is active is recorded here (the viewstream/seed
|
|
57
|
+
# ambient-signal pattern in `app.py`, applied to CONSTRUCTION rather than a
|
|
58
|
+
# submit flag). This is what lets an UNMOUNTED registry handle
|
|
59
|
+
# (`forklift = simulo.Asset.from_registry(...)`, used only via
|
|
60
|
+
# `Robot(asset=forklift)`, never passed to `App(mounts=...)`) still get
|
|
61
|
+
# resolved and pinned: `simulo run` arms this scope BEFORE importing the app
|
|
62
|
+
# file (so module-level constructions are seen) and keeps it armed through the
|
|
63
|
+
# whole entrypoint invocation. `bundle.build_manifest` unions the captured
|
|
64
|
+
# assets into the manifest's `resources` list alongside the App's own
|
|
65
|
+
# `mounts=`-declared ones (authored-ref mount invariant — "Handle collection
|
|
66
|
+
# is CONSTRUCTION-CAPTURE, not mount-only").
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
_CONSTRUCTION_CAPTURE: Optional[List["Asset"]] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextlib.contextmanager
|
|
73
|
+
def capture_constructed_assets() -> Iterator[List["Asset"]]:
|
|
74
|
+
"""Collect every :class:`Asset` constructed in this scope (nestable; the
|
|
75
|
+
innermost active scope receives each construction)."""
|
|
76
|
+
global _CONSTRUCTION_CAPTURE
|
|
77
|
+
previous = _CONSTRUCTION_CAPTURE
|
|
78
|
+
captured: List["Asset"] = []
|
|
79
|
+
_CONSTRUCTION_CAPTURE = captured
|
|
80
|
+
try:
|
|
81
|
+
yield captured
|
|
82
|
+
finally:
|
|
83
|
+
_CONSTRUCTION_CAPTURE = previous
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current_captured_assets() -> Tuple["Asset", ...]:
|
|
87
|
+
"""Every :class:`Asset` constructed so far in the active capture scope, or
|
|
88
|
+
``()`` when no scope is active (e.g. a direct ``JobFunction.spawn()`` call
|
|
89
|
+
outside ``simulo run`` — that path still resolves whatever the App's own
|
|
90
|
+
``mounts=`` declares; construction-capture only ADDS coverage for
|
|
91
|
+
unmounted handles, it is never the sole source of truth)."""
|
|
92
|
+
if _CONSTRUCTION_CAPTURE is None:
|
|
93
|
+
return ()
|
|
94
|
+
return tuple(_CONSTRUCTION_CAPTURE)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class _AssetSpec:
|
|
99
|
+
"""Inert, torch-free model-asset spec produced in discovery mode.
|
|
100
|
+
|
|
101
|
+
Records the *kind* (``"usd"`` / ``"urdf"``) and source ``path`` a user named via
|
|
102
|
+
``Asset.usd(...)`` / ``Asset.urdf(...)``. It is never used for computation at
|
|
103
|
+
submit — the real model-source asset is materialised on the worker.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
kind: str
|
|
107
|
+
path: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_registry_ref(uri: str) -> ParsedAssetRef:
|
|
111
|
+
"""Parse a registry ref through the shared grammar, re-raising as a
|
|
112
|
+
:class:`ContractViolationError` (the SDK's user-facing input-error type).
|
|
113
|
+
|
|
114
|
+
The wire grammar raises :class:`ValueError` with a user-language-first
|
|
115
|
+
message; the SDK surface speaks ``ContractViolationError``, so the message is
|
|
116
|
+
preserved and only the type is adapted — a bad ref reads the same whether it
|
|
117
|
+
reaches the user through the CLI resolver or this handle.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
return parse_asset_ref(uri)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise ContractViolationError(str(exc)) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _asset_cache_root() -> Path:
|
|
126
|
+
"""The local catalog cache root — ``~/.simulo/assets`` (what ``simulo asset
|
|
127
|
+
get`` writes into). Reads ``$HOME`` via :meth:`Path.home` so test isolation
|
|
128
|
+
(a redirected ``HOME``) is honored in-process, exactly like the credentials
|
|
129
|
+
path."""
|
|
130
|
+
return Path.home() / ".simulo" / "assets"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Asset:
|
|
134
|
+
"""A read-only, registry-resolved resource mounted into running jobs.
|
|
135
|
+
|
|
136
|
+
A registry ref is ``[<publisher>/]<kind>/<name>[:v<N>]`` (e.g.
|
|
137
|
+
``simulo/robot/cartpole:v1``); :meth:`from_registry` names one, and :attr:`path`
|
|
138
|
+
resolves to its read-only mount inside a running job. ``Asset`` is also how a
|
|
139
|
+
task names USD/URDF model sources to load into a scene — :meth:`usd` /
|
|
140
|
+
:meth:`urdf`.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ContractViolationError: if ``uri`` is not a valid
|
|
144
|
+
``[<publisher>/]<kind>/<name>[:v<N>]`` ref.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, uri: str, *, read_only: bool = True) -> None:
|
|
148
|
+
self._ref = _parse_registry_ref(uri)
|
|
149
|
+
self._uri = uri
|
|
150
|
+
self._read_only = read_only
|
|
151
|
+
if _CONSTRUCTION_CAPTURE is not None:
|
|
152
|
+
_CONSTRUCTION_CAPTURE.append(self)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_registry(cls, uri: str, read_only: bool = True) -> "Asset":
|
|
156
|
+
"""Get a handle to the registry asset at ``uri``.
|
|
157
|
+
|
|
158
|
+
``uri`` is a catalog ref ``[<publisher>/]<kind>/<name>[:v<N>]`` — the
|
|
159
|
+
publisher defaults to your own org, ``simulo`` names the global catalog,
|
|
160
|
+
and an omitted ``:v<N>`` is resolved to the current latest **at submit**.
|
|
161
|
+
The handle is directly consumable wherever a model source is
|
|
162
|
+
accepted — ``Robot(asset=...)`` for a ``robot`` ref, ``scene.add(...)``
|
|
163
|
+
for a ``world`` ref.
|
|
164
|
+
"""
|
|
165
|
+
return cls(uri, read_only=read_only)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def usd(cls, path: str) -> Any:
|
|
169
|
+
"""Name a USD model source to load into a scene.
|
|
170
|
+
|
|
171
|
+
At submit time this returns an inert, torch-free spec object so authoring
|
|
172
|
+
stays lean; in the execution runtime it resolves to the real model asset. The return
|
|
173
|
+
type depends on mode, so it is typed ``Any``.
|
|
174
|
+
"""
|
|
175
|
+
return cls._model_asset("usd", path)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def urdf(cls, path: str) -> Any:
|
|
179
|
+
"""Name a URDF model source to load into a scene (see :meth:`usd`)."""
|
|
180
|
+
return cls._model_asset("urdf", path)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _model_asset(kind: str, path: str) -> Any:
|
|
184
|
+
if current_mode() == EXECUTION:
|
|
185
|
+
# The model-source AssetSource is the dependency-free
|
|
186
|
+
# ``simulo.interfaces.authoring.asset.AssetSource`` (single definition,
|
|
187
|
+
# relocated out of the backend in Plan C) — importable statically
|
|
188
|
+
# and torch-free, so no lazy import is needed any more.
|
|
189
|
+
factory = getattr(_ModelSourceAsset, kind)
|
|
190
|
+
return factory(path)
|
|
191
|
+
return _AssetSpec(kind=kind, path=path)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def uri(self) -> str:
|
|
195
|
+
"""The registry ref as authored (``[<publisher>/]<kind>/<name>[:v<N>]``)."""
|
|
196
|
+
return self._uri
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def kind(self) -> str:
|
|
200
|
+
"""The ``<kind>`` segment of the ref — ``"robot"``, ``"world"``, or ``"prop"``."""
|
|
201
|
+
return self._ref.kind
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def name(self) -> str:
|
|
205
|
+
"""The asset ``<name>`` slug."""
|
|
206
|
+
return self._ref.name
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def publisher(self) -> Optional[str]:
|
|
210
|
+
"""The owning catalog publisher (``"simulo"`` = global, an org slug, or
|
|
211
|
+
``None`` when omitted — the caller's own org)."""
|
|
212
|
+
return self._ref.publisher
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def version(self) -> Optional[int]:
|
|
216
|
+
"""The pinned version number, or ``None`` for an unpinned ref (resolved at
|
|
217
|
+
submit)."""
|
|
218
|
+
return self._ref.version
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_read_only(self) -> bool:
|
|
222
|
+
"""Whether this handle is read-only (registry assets always are)."""
|
|
223
|
+
return self._read_only
|
|
224
|
+
|
|
225
|
+
def read_only(self) -> "Asset":
|
|
226
|
+
"""Return a read-only view of this asset (builder marker)."""
|
|
227
|
+
return Asset(self._uri, read_only=True)
|
|
228
|
+
|
|
229
|
+
def require_kind(self, expected: str) -> None:
|
|
230
|
+
"""Fail with a one-line explanation if this asset's kind is not ``expected``.
|
|
231
|
+
|
|
232
|
+
The consumption-side half of **kind safety**: a ``robot`` context
|
|
233
|
+
(``Robot(asset=...)``) requires a ``robot`` asset, a ``world`` context a
|
|
234
|
+
``world`` asset. ``simulo.core.Robot`` calls this (duck-typed) so
|
|
235
|
+
``Robot(asset=simulo.Asset.from_registry("world/warehouse:v2"))`` fails
|
|
236
|
+
with a clear message rather than surfacing later as an opaque runtime
|
|
237
|
+
error. The kind is known from the ref itself, so the check is a cheap
|
|
238
|
+
string comparison — no catalog lookup.
|
|
239
|
+
"""
|
|
240
|
+
if self._ref.kind == expected:
|
|
241
|
+
return
|
|
242
|
+
message = (
|
|
243
|
+
f"asset {self._uri!r} is a {self._ref.kind!r} asset, but a {expected!r} asset is required here — "
|
|
244
|
+
f"use a {expected}/... ref."
|
|
245
|
+
)
|
|
246
|
+
if expected == "robot":
|
|
247
|
+
# The common mix-up: a world handed to Robot(asset=...). Name the right
|
|
248
|
+
# consumption site so the fix is obvious.
|
|
249
|
+
message += " A world asset is added to the scene with scene.add(...), not built as a robot."
|
|
250
|
+
raise ContractViolationError(message)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def path(self) -> str:
|
|
254
|
+
"""Local read-only mount path — only meaningful inside a running job.
|
|
255
|
+
|
|
256
|
+
Resolution order, in execution mode:
|
|
257
|
+
|
|
258
|
+
1. ``SIMULO_ASSET_<uri>`` — the mount the execution environment
|
|
259
|
+
exported for this asset. When set, it is authoritative.
|
|
260
|
+
2. The on-disk catalog cache ``~/.simulo/assets/<org>/<kind>/<name>/v<N>/``
|
|
261
|
+
— the colon-free layout ``simulo asset get`` writes into. Used for a
|
|
262
|
+
local run with no runner-provided mount.
|
|
263
|
+
|
|
264
|
+
Raises outside execution mode, or — when neither a mount env var nor a
|
|
265
|
+
cached copy is present — with the exact ``simulo asset get <ref>`` command
|
|
266
|
+
to fetch it.
|
|
267
|
+
"""
|
|
268
|
+
if current_mode() != EXECUTION:
|
|
269
|
+
raise ResourceNotFoundError(
|
|
270
|
+
f"Asset {self._uri!r}.path is only available inside a running job "
|
|
271
|
+
f"(SIMULO_MODE={EXECUTION!r}); it is not resolvable during packaging."
|
|
272
|
+
)
|
|
273
|
+
mount = os.environ.get(asset_env_key(self._uri))
|
|
274
|
+
if mount is not None:
|
|
275
|
+
return mount
|
|
276
|
+
cached = self._resolve_from_cache()
|
|
277
|
+
if cached is not None:
|
|
278
|
+
return str(cached)
|
|
279
|
+
raise ResourceNotFoundError(
|
|
280
|
+
f"Asset {self._uri!r} is not mounted and no local copy was found. Fetch it first:\n"
|
|
281
|
+
f" simulo asset get {self._uri}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _resolve_from_cache(self) -> Optional[Path]:
|
|
285
|
+
"""Find this asset in ``~/.simulo/assets/<org>/<kind>/<name>/v<N>/``, or ``None``.
|
|
286
|
+
|
|
287
|
+
**No silent cross-catalog fallback** (authored-ref mount invariant): an
|
|
288
|
+
explicit-publisher ref (``simulo`` for the global catalog, or an org slug)
|
|
289
|
+
resolves under that ONE catalog directory only. An omitted-publisher ref
|
|
290
|
+
means the caller's OWN org and NOTHING ELSE — every :data:`RESERVED_ASSET_PUBLISHERS`
|
|
291
|
+
directory (chief among them ``simulo``, the global catalog) is excluded from
|
|
292
|
+
its glob, so a cached global copy can never silently satisfy an own-org ref.
|
|
293
|
+
The caller's actual org slug is not resolvable client-side (saved
|
|
294
|
+
credentials carry only ``active_organization_id``, a UUID, never a slug),
|
|
295
|
+
so "not a reserved word" is the best available own-org filter.
|
|
296
|
+
|
|
297
|
+
Zero or ambiguous (``> 1``) matches resolve to ``None`` (the caller renders
|
|
298
|
+
the generic ``simulo asset get <ref>`` hint). The one exception: an
|
|
299
|
+
omitted-publisher ref with ZERO own-org matches but a cache hit under the
|
|
300
|
+
GLOBAL catalog raises directly here, naming the exact global ref — a
|
|
301
|
+
cross-catalog hit is real, useful information, not a plain miss.
|
|
302
|
+
"""
|
|
303
|
+
root = _asset_cache_root()
|
|
304
|
+
version_glob = f"v{self._ref.version}" if self._ref.version is not None else "v*"
|
|
305
|
+
|
|
306
|
+
if self._ref.publisher is not None:
|
|
307
|
+
pattern = f"{self._ref.publisher}/{self._ref.kind}/{self._ref.name}/{version_glob}"
|
|
308
|
+
matches = sorted(p for p in root.glob(pattern) if p.is_dir())
|
|
309
|
+
return matches[0] if len(matches) == 1 else None
|
|
310
|
+
|
|
311
|
+
pattern = f"*/{self._ref.kind}/{self._ref.name}/{version_glob}"
|
|
312
|
+
all_matches = sorted(p for p in root.glob(pattern) if p.is_dir())
|
|
313
|
+
own_org_matches = [p for p in all_matches if p.relative_to(root).parts[0] not in RESERVED_ASSET_PUBLISHERS]
|
|
314
|
+
if len(own_org_matches) == 1:
|
|
315
|
+
return own_org_matches[0]
|
|
316
|
+
if not own_org_matches:
|
|
317
|
+
global_matches = [p for p in all_matches if p.relative_to(root).parts[0] == GLOBAL_ASSET_PUBLISHER]
|
|
318
|
+
if global_matches:
|
|
319
|
+
hint_ref = format_asset_ref(
|
|
320
|
+
publisher=GLOBAL_ASSET_PUBLISHER,
|
|
321
|
+
kind=self._ref.kind,
|
|
322
|
+
name=self._ref.name,
|
|
323
|
+
version=int(global_matches[0].name.removeprefix("v")),
|
|
324
|
+
)
|
|
325
|
+
raise ResourceNotFoundError(
|
|
326
|
+
f"Asset {self._uri!r} has no local copy under your own org's cache, but the GLOBAL "
|
|
327
|
+
f"catalog has one cached. Catalogs never fall back to each other silently — if you "
|
|
328
|
+
f"meant the global asset, use its explicit ref:\n"
|
|
329
|
+
f" simulo asset get {hint_ref}"
|
|
330
|
+
)
|
|
331
|
+
return None # zero, or ambiguous (>1), own-org matches
|