This is an automated email from the ASF dual-hosted git repository.
tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git
The following commit(s) were added to refs/heads/main by this push:
new a6071f6 [EXTRA][FEAT] VisitErrorContext for visit-context-aware error
reporting (#587)
a6071f6 is described below
commit a6071f671364563012cd830c98d6df1d935f880f
Author: Tianqi Chen <[email protected]>
AuthorDate: Thu May 21 14:32:20 2026 -0700
[EXTRA][FEAT] VisitErrorContext for visit-context-aware error reporting
(#587)
## Summary
Introduces `VisitErrorContext`, a typed payload for
`Error::extra_context` that records the chain of `ObjectRef` nodes
visited during a recursive visit when an error is thrown. This enables
visit-context-aware error reporting with structured access paths.
### Key components
- **`VisitErrorContextObj` / `VisitErrorContext`**
(`include/tvm/ffi/extra/visit_error_context.h`): Object/Ref pair that
stores `reverse_visit_pattern` (the breadcrumb chain of visited nodes,
innermost-first) and `prev_error_context` (any pre-existing error
context that was displaced).
- **Macros** for instrumenting recursive visits:
- `TVM_FFI_VISIT_BEGIN()` / `TVM_FFI_VISIT_END(node)`: Wrap a visit body
in a try/catch that appends `node` to the error context on unwind.
- `TVM_FFI_VISIT_THROW(ErrorKind, node)`: Throw an error with `node` as
the innermost frame of the context (for pinpointing a child field as the
error site).
- **`VisitErrorContext::FindAccessPaths(root, ctx)`**: Resolves the
breadcrumb chain against a tree rooted at `root`, producing
`reflection::AccessPath` instances (e.g., `.body[2].cond.lhs`) for
richer error messages.
- **`VisitErrorAccessPathFinder`** (internal, in `.cc`): DFS matcher
that walks the tree using reflection field info, matching the breadcrumb
pattern with support for Attr/ArrayItem/MapItem access kinds, CSE
multi-match, sparse anchors, prefix matching, and
consecutive-duplicate/null cleanup.
### Tests
Comprehensive GoogleTest coverage in
`tests/cpp/extra/test_visit_error_context.cc`:
- Layer A (Macro to Chain): `MacroBuildsChain`,
`MacroPreExistingPayloadWrap`, `MacroThrowBuildsChain`
- Layer B (Chain to AccessPaths): `BasicMatch`, `AccessKindCoverage`,
`SparsePatternAnchors`, `PartialChain`, `EdgeCases`, `RecordsCleanup`
- Integration: `TryGetFromError`
---
CMakeLists.txt | 1 +
docs/conf.py | 3 +
include/tvm/ffi/error.h | 13 +-
include/tvm/ffi/extra/visit_error_context.h | 255 +++++++++++
python/tvm_ffi/cython/error.pxi | 17 +
src/ffi/extra/visit_error_context.cc | 331 ++++++++++++++
tests/cpp/extra/test_visit_error_context.cc | 652 ++++++++++++++++++++++++++++
7 files changed, 1271 insertions(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6f07d4a..018f8c3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,6 +75,7 @@ set(_tvm_ffi_objs_sources
set(_tvm_ffi_extra_objs_sources
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/structural_equal.cc"
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/structural_hash.cc"
+ "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/visit_error_context.cc"
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/json_parser.cc"
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/json_writer.cc"
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/serialization.cc"
diff --git a/docs/conf.py b/docs/conf.py
index 14eb15b..07befa1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -90,6 +90,8 @@ exhaleDoxygenStdin = """
INPUT = ../include
PREDEFINED += TVM_FFI_DLL= TVM_FFI_DLL_EXPORT= TVM_FFI_INLINE= \
TVM_FFI_EXTRA_CXX_API= TVM_FFI_WEAK=
TVM_FFI_DOXYGEN_MODE \
+ TVM_FFI_COLD_CODE= \
+ TVM_FFI_PREDICT_FALSE(x)=x TVM_FFI_PREDICT_TRUE(x)=x
\
__cplusplus=201703
EXCLUDE_SYMBOLS += *details* *TypeTraits* std \
*use_default_type_traits_v* *is_optional_type_v*
*operator* \
@@ -124,6 +126,7 @@ cpp_id_attributes = [
"TVM_FFI_INLINE",
"TVM_FFI_EXTRA_CXX_API",
"TVM_FFI_WEAK",
+ "TVM_FFI_COLD_CODE",
]
c_id_attributes = [
diff --git a/include/tvm/ffi/error.h b/include/tvm/ffi/error.h
index 4931703..5421d6d 100644
--- a/include/tvm/ffi/error.h
+++ b/include/tvm/ffi/error.h
@@ -349,6 +349,14 @@ class ErrorBuilder {
: ErrorBuilder(std::move(kind), std::string(backtrace->data,
backtrace->size),
log_before_throw) {}
+ TVM_FFI_COLD_CODE
+ explicit ErrorBuilder(std::string kind, const TVMFFIByteArray* backtrace,
bool log_before_throw,
+ std::optional<Error> cause_chain,
std::optional<ObjectRef> extra_context)
+ : ErrorBuilder(std::move(kind), backtrace, log_before_throw) {
+ cause_chain_ = std::move(cause_chain);
+ extra_context_ = std::move(extra_context);
+ }
+
// MSVC disable warning in error builder as it is exepected
#ifdef _MSC_VER
#pragma warning(push)
@@ -356,7 +364,8 @@ class ErrorBuilder {
#endif
// avoid inline to reduce binary size, error throw path do not need to be
fast
[[noreturn]] TVM_FFI_COLD_CODE ~ErrorBuilder() noexcept(false) {
- ::tvm::ffi::Error error(std::move(kind_), stream_.str(),
std::move(backtrace_));
+ ::tvm::ffi::Error error(std::move(kind_), stream_.str(),
std::move(backtrace_),
+ std::move(cause_chain_),
std::move(extra_context_));
if (log_before_throw_) {
std::cerr << error.FullMessage();
}
@@ -373,6 +382,8 @@ class ErrorBuilder {
std::ostringstream stream_;
std::string backtrace_;
bool log_before_throw_;
+ std::optional<Error> cause_chain_;
+ std::optional<ObjectRef> extra_context_;
};
} // namespace details
diff --git a/include/tvm/ffi/extra/visit_error_context.h
b/include/tvm/ffi/extra/visit_error_context.h
new file mode 100644
index 0000000..fd9310c
--- /dev/null
+++ b/include/tvm/ffi/extra/visit_error_context.h
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/*!
+ * \file tvm/ffi/extra/visit_error_context.h
+ * \brief VisitErrorContext: typed payload for Error::extra_context that
records the
+ * chain of ObjectRefs visited during a recursive visit when an error
is thrown.
+ */
+#ifndef TVM_FFI_EXTRA_VISIT_ERROR_CONTEXT_H_
+#define TVM_FFI_EXTRA_VISIT_ERROR_CONTEXT_H_
+
+#include <tvm/ffi/container/array.h>
+#include <tvm/ffi/container/list.h>
+#include <tvm/ffi/error.h>
+#include <tvm/ffi/extra/base.h>
+#include <tvm/ffi/memory.h>
+#include <tvm/ffi/object.h>
+#include <tvm/ffi/optional.h>
+#include <tvm/ffi/reflection/access_path.h>
+
+namespace tvm {
+namespace ffi {
+
+/*!
+ * \brief Object class for VisitErrorContext.
+ *
+ * \sa VisitErrorContext
+ */
+class VisitErrorContextObj : public Object {
+ public:
+ /*!
+ * \brief Visit records that get populated, which include the object visit
+ * path pattern in innermost-first order. Best-effort — not
exhaustive.
+ */
+ List<ObjectRef> reverse_visit_pattern;
+
+ /*!
+ * \brief Pre-existing Error::extra_context payload before we placed the
+ * VisitErrorContext.
+ */
+ Optional<ObjectRef> prev_error_context;
+
+ /// \cond Doxygen_Suppress
+ static constexpr bool _type_mutable = true;
+ static constexpr TVMFFISEqHashKind _type_s_eq_hash_kind =
kTVMFFISEqHashKindUnsupported;
+ TVM_FFI_DECLARE_OBJECT_INFO_FINAL("ffi.VisitErrorContext",
VisitErrorContextObj, Object);
+ /// \endcond
+};
+
+/*!
+ * \brief Typed payload attached to Error::extra_context to support
+ * visit-context-aware error reporting.
+ *
+ * The VisitErrorContext captures the reverse_visit_pattern —
+ * the chain of nodes visited before an error was thrown — so callers
+ * can translate it via FindAccessPaths into a structured access path
+ * for richer error messages.
+ *
+ * Typical usage:
+ *
+ * 1. A recursive visit is instrumented with
+ * TVM_FFI_VISIT_BEGIN / _END(node). The deepest detection
+ * point throws via TVM_FFI_VISIT_THROW(kind, node), which
+ * seeds the context with the throw site; enclosing BEGIN/END pairs
+ * append parent nodes on rethrow.
+ *
+ * 2. The root catch handler retrieves the context via
+ * TryGetFromError(err), then resolves the chain into one or more
+ * reflection::AccessPath instances via FindAccessPaths(root, ctx).
+ *
+ * 3. The caller uses the AccessPath to enrich the error message
+ * with structured position info (e.g., ".body[2].cond.lhs").
+ */
+class VisitErrorContext : public ObjectRef {
+ public:
+ /*! \brief Get the VisitErrorContext attached to err's extra_context.
+ * \param err The error to inspect.
+ * \return The attached VisitErrorContext, or NullOpt if absent.
+ */
+ TVM_FFI_COLD_CODE
+ static Optional<VisitErrorContext> TryGetFromError(const Error& err) {
+ std::optional<ObjectRef> extra_context = err.extra_context();
+ if (extra_context) {
+ return extra_context->as<VisitErrorContext>();
+ }
+ return std::nullopt;
+ }
+
+ /*! \brief Find all access paths that match the pattern specified in the
+ * VisitErrorContext.
+ * \param root The root ObjectRef to search from.
+ * \param visit_context The VisitErrorContext to match against.
+ * \param allow_prefix_match If true, also report paths where only a
+ * prefix of the pattern was matched (i.e.,
+ * the algorithm descended through some
+ * matched records but could not find further
+ * matches before reaching a leaf). Default
+ * false — only full pattern matches are
+ * reported.
+ * \return Array of matched access paths.
+ */
+ TVM_FFI_COLD_CODE
+ TVM_FFI_EXTRA_CXX_API static Array<reflection::AccessPath> FindAccessPaths(
+ const ObjectRef& root, const VisitErrorContext& visit_context,
+ bool allow_prefix_match = false);
+
+ /// \cond Doxygen_Suppress
+ explicit VisitErrorContext(ObjectPtr<VisitErrorContextObj> n) :
ObjectRef(std::move(n)) {}
+ TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE(VisitErrorContext, ObjectRef,
VisitErrorContextObj);
+ /// \endcond
+};
+
+/*!
+ * \brief Begin a visit try block.
+ *
+ * Must be paired with TVM_FFI_VISIT_END(node) at the end of the
+ * visit body. Expands to an open `try {` — a mismatched _END macro is a
+ * compile error (unclosed try block).
+ *
+ * \code{.cpp}
+ * void MyVisitor::VisitNode(const ObjectRef& node) {
+ * TVM_FFI_VISIT_BEGIN();
+ * DispatchVisit(node);
+ * TVM_FFI_VISIT_END(node);
+ * }
+ * \endcode
+ */
+#define TVM_FFI_VISIT_BEGIN() try {
+/*!
+ * \brief End a visit try block and catch+re-throw any Error,
+ * appending node to the VisitErrorContext on the way up.
+ *
+ * Must be paired with TVM_FFI_VISIT_BEGIN() above the visit body.
+ *
+ * \param node The ObjectRef at the current visit level (appended to the
+ * error context's reverse_visit_pattern on exception).
+ */
+#define TVM_FFI_VISIT_END(node)
\
+ }
\
+ catch (::tvm::ffi::Error & _tvm_ffi_visit_err_) {
\
+ ::tvm::ffi::details::UpdateVisitErrorContext(_tvm_ffi_visit_err_, (node));
\
+ throw;
\
+ }
+
+/*!
+ * \brief Throw an error from inside a visit, with `node` recorded
+ * as the innermost frame of the resulting VisitErrorContext.
+ *
+ * Use this when the bad spot is somewhere the BEGIN/END pair does not
+ * already record — typically a child field of the currently-visited node,
+ * or a helper called from a visit that has no BEGIN/END of its own. The
+ * throw site is seeded as the innermost frame; enclosing
+ * TVM_FFI_VISIT_BEGIN/END pairs continue to append their nodes
+ * on rethrow as the stack unwinds. The macro mirrors TVM_FFI_THROW —
+ * it returns an ostream you stream a message into.
+ *
+ * If `node` here is the same as the enclosing END's node (a redundant
+ * throw at the same level), FindAccessPaths normalizes the consecutive
+ * duplicate during matching, so user code does not need to guard against
+ * it — but in that case the throw would have been recorded by END
+ * anyway and a plain TVM_FFI_THROW would suffice.
+ *
+ * \code{.cpp}
+ * // Visiting a TPair node; pin the bad subfield (.lhs) as the throw
+ * // site so the resulting AccessPath ends at .lhs, not at the TPair
+ * // node itself. The surrounding END appends `node` as the next frame.
+ * void Visitor::Visit(const ObjectRef& node) {
+ * TVM_FFI_VISIT_BEGIN();
+ * if (auto pair = node.as<TPair>()) {
+ * if (!IsValid(pair.value()->lhs)) {
+ * TVM_FFI_VISIT_THROW(ValueError, pair.value()->lhs)
+ * << "invalid lhs";
+ * }
+ * }
+ * TVM_FFI_VISIT_END(node);
+ * }
+ * \endcode
+ *
+ * \param ErrorKind The kind of error to throw (e.g. TypeError, ValueError).
+ * \param node The ObjectRef at the throw site (innermost frame).
+ */
+#define TVM_FFI_VISIT_THROW(ErrorKind, node)
\
+ ::tvm::ffi::details::ErrorBuilder(
\
+ #ErrorKind, TVMFFIBacktrace(__FILE__, __LINE__, TVM_FFI_FUNC_SIG, 0),
\
+ TVM_FFI_ALWAYS_LOG_BEFORE_THROW, ::std::nullopt,
\
+
::std::optional<::tvm::ffi::ObjectRef>(::tvm::ffi::details::MakeVisitErrorContext(node)))
\
+ .stream()
+
+namespace details {
+/*!
+ * \brief Build a fresh VisitErrorContext seeded with `node` as the
+ * innermost (and currently only) frame of reverse_visit_pattern.
+ *
+ * Used by TVM_FFI_VISIT_THROW to attach the throw-site
+ * node to the Error's extra_context at construction time.
+ */
+TVM_FFI_COLD_CODE
+inline VisitErrorContext MakeVisitErrorContext(const ObjectRef& node) {
+ ObjectPtr<VisitErrorContextObj> obj = make_object<VisitErrorContextObj>();
+ obj->reverse_visit_pattern = List<ObjectRef>{node};
+ return VisitErrorContext(std::move(obj));
+}
+
+/*!
+ * \brief Implementation helper for TVM_FFI_VISIT_END(node).
+ * Calling convention may change; do not call directly from user code.
+ */
+TVM_FFI_COLD_CODE
+inline void UpdateVisitErrorContext(Error& err, const ObjectRef& node) { //
NOLINT(*)
+ // NOTE: This function mutates the ErrorObj in place via ObjectUnsafe.
+ // Expected to run only inside the exception throw chain, where the Error
+ // is single-owned by this thread. The tradeoff avoids reallocating a
+ // fresh Error per catch frame; the immutability invariant returns once
+ // the unwind window closes.
+ std::optional<ObjectRef> extra_context = err.extra_context();
+ if (extra_context) {
+ Optional<VisitErrorContext> visit_context =
extra_context->as<VisitErrorContext>();
+ if (visit_context) {
+ visit_context.value()->reverse_visit_pattern.push_back(node);
+ return;
+ }
+ }
+ // Build a fresh VisitErrorContext, preserving any pre-existing payload.
+ ObjectPtr<VisitErrorContextObj> new_context =
make_object<VisitErrorContextObj>();
+ new_context->reverse_visit_pattern = List<ObjectRef>{node};
+ if (extra_context) new_context->prev_error_context = *extra_context;
+
+ ErrorObj* error_obj =
+
static_cast<ErrorObj*>(details::ObjectUnsafe::RawObjectPtrFromObjectRef(err));
+ if (error_obj->extra_context != nullptr) {
+ details::ObjectUnsafe::DecRefObjectHandle(error_obj->extra_context);
+ }
+ error_obj->extra_context =
+
details::ObjectUnsafe::MoveObjectPtrToTVMFFIObjectPtr(std::move(new_context));
+}
+} // namespace details
+
+} // namespace ffi
+} // namespace tvm
+#endif // TVM_FFI_EXTRA_VISIT_ERROR_CONTEXT_H_
diff --git a/python/tvm_ffi/cython/error.pxi b/python/tvm_ffi/cython/error.pxi
index cbd1194..16390ed 100644
--- a/python/tvm_ffi/cython/error.pxi
+++ b/python/tvm_ffi/cython/error.pxi
@@ -104,6 +104,23 @@ cdef class Error(CObject):
def backtrace(self):
return
bytearray_to_str(&(TVMFFIErrorGetCellPtr(self.chandle).backtrace))
+ @property
+ def extra_context(self):
+ """Optional structured payload attached to this error.
+
+ Returns ``None`` if nothing is attached. May be inspected via the
+ appropriate type-specific helpers.
+ """
+ cdef TVMFFIObjectHandle ctx_handle =
TVMFFIErrorGetCellPtr(self.chandle).extra_context
+ if ctx_handle == NULL:
+ return None
+ # Build an owned Any from the unowned handle by incrementing the
refcount.
+ cdef TVMFFIAny any_val
+ any_val.type_index = TVMFFIObjectGetTypeIndex(ctx_handle)
+ any_val.v_obj = <TVMFFIObject*>ctx_handle
+ TVMFFIObjectIncRef(ctx_handle)
+ return make_ret_object(any_val)
+
cdef inline Error move_from_last_error():
# raise last error
diff --git a/src/ffi/extra/visit_error_context.cc
b/src/ffi/extra/visit_error_context.cc
new file mode 100644
index 0000000..bd2f0a8
--- /dev/null
+++ b/src/ffi/extra/visit_error_context.cc
@@ -0,0 +1,331 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/*
+ * \file src/ffi/extra/visit_error_context.cc
+ * \brief VisitErrorContext implementation — breadcrumb-trail collection and
access-path
+ * extraction for recursive Object visits.
+ */
+#include <tvm/ffi/container/array.h>
+#include <tvm/ffi/container/dict.h>
+#include <tvm/ffi/container/list.h>
+#include <tvm/ffi/container/map.h>
+#include <tvm/ffi/extra/visit_error_context.h>
+#include <tvm/ffi/memory.h>
+#include <tvm/ffi/reflection/accessor.h>
+#include <tvm/ffi/string.h>
+
+#include <utility>
+#include <vector>
+
+namespace tvm {
+namespace ffi {
+
+/**
+ * \brief Internal handler for finding all access paths in a root ObjectRef
+ * that match the breadcrumb pattern stored in a VisitErrorContext.
+ */
+class VisitErrorAccessPathFinder {
+ public:
+ TVM_FFI_COLD_CODE
+ explicit VisitErrorAccessPathFinder(VisitErrorContext context, bool
allow_prefix_match)
+ : context_(std::move(context)), allow_prefix_match_(allow_prefix_match) {
+ // Normalize the breadcrumb pattern before matching. Two kinds of noise can
+ // accumulate in reverse_visit_pattern at recording time:
+ //
+ // 1. Consecutive duplicates of the same ObjectRef. This is expected when
+ // TVM_FFI_VISIT_THROW(kind, node) and an enclosing
+ // TVM_FFI_VISIT_END(node) at the same level both record
+ // the same `node` — the throw site seeds the context with `node`, and
+ // the catch handler immediately above appends `node` again. Treat any
+ // run of identical adjacent entries as a single frame.
+ //
+ // 2. Null / undefined ObjectRefs. These can leak in when a visited node
+ // was mutated or torn down between recording and matching, leaving a
+ // stale slot. We can never match a null pointer against a live tree,
+ // so drop them silently rather than letting them break the chain.
+ //
+ // The matcher operates on `records_` from here on; the original
+ // `context_->reverse_visit_pattern` is left untouched so callers that
+ // inspect the context for debugging still see the raw history.
+ const List<ObjectRef>& raw = context_->reverse_visit_pattern;
+ records_.reserve(raw.size());
+ for (const ObjectRef& entry : raw) {
+ if (!entry.defined()) continue;
+ if (!records_.empty() && entry.same_as(records_.back())) continue;
+ records_.push_back(entry);
+ }
+ }
+
+ TVM_FFI_COLD_CODE
+ Array<reflection::AccessPath> Find(const ObjectRef& root) {
+ this->VisitObject(root);
+ return Array<reflection::AccessPath>(results_.begin(), results_.end());
+ }
+
+ private:
+ /**
+ * \brief Stack-allocated mirror of AccessStepObj used during the descent
hot path.
+ * For kAttr: stores const TVMFFIFieldInfo* encoded as void* in key
(via
+ * kTVMFFIOpaquePtr). String allocation is deferred to ToAccessStep().
+ * Not an Object — pure struct, no Object header / refcount.
+ */
+ struct TempAccessStep {
+ reflection::AccessKind kind;
+ Any key{}; // For kAttr: FieldInfo pointer encoded as void*
(kTVMFFIOpaquePtr).
+ // For kArrayItem: int64 index.
+ // For kMapItem: the user's key.
+
+ static TempAccessStep Attr(const TVMFFIFieldInfo* fi) {
+ TempAccessStep s;
+ s.kind = reflection::AccessKind::kAttr;
+ // We store `fi` as an OPAQUE POINTER inside `Any` (kTVMFFIOpaquePtr).
+ // `Any` is used here purely as a value-carrying slot for the bits of
+ // the pointer — it does NOT take ownership, does NOT dereference
+ // through it, and does NOT mutate the pointee. The FieldInfo struct
+ // is read-only metadata owned by the type-info registry and lives
+ // for the lifetime of the type info, so the underlying object is
+ // unaffected by the const_cast below.
+ //
+ // The const_cast is needed only because `Any` provides TypeTraits for
+ // `void*` but not for `const void*`. On retrieval (ToAccessStep()
+ // below) we cast back to `const TVMFFIFieldInfo*` before any use.
+ s.key = Any(const_cast<void*>(static_cast<const void*>(fi)));
+ return s;
+ }
+
+ static TempAccessStep ArrayItem(int64_t index) {
+ TempAccessStep s;
+ s.kind = reflection::AccessKind::kArrayItem;
+ s.key = Any(index);
+ return s;
+ }
+
+ static TempAccessStep MapItem(Any k) {
+ TempAccessStep s;
+ s.kind = reflection::AccessKind::kMapItem;
+ s.key = std::move(k);
+ return s;
+ }
+
+ /*! \brief Materialize the heap-allocated AccessStep. Called only at match
time.
+ * The String allocation for fi->name happens HERE, once. */
+ reflection::AccessStep ToAccessStep() const {
+ if (kind == reflection::AccessKind::kAttr) {
+ // Recover the original `const TVMFFIFieldInfo*` from the opaque
+ // pointer stored in `key`. The bits round-trip unchanged; the
+ // const-qualifier is restored here, immediately before any use.
+ const TVMFFIFieldInfo* fi = static_cast<const
TVMFFIFieldInfo*>(key.cast<void*>());
+ return reflection::AccessStep::Attr(String(fi->name.data,
fi->name.size));
+ } else if (kind == reflection::AccessKind::kArrayItem) {
+ return reflection::AccessStep::ArrayItem(key.cast<int64_t>());
+ } else {
+ // kMapItem
+ return reflection::AccessStep::MapItem(key);
+ }
+ }
+ };
+
+ void VisitAny(Any value) {
+ // Skip null Any silently — error-path defensive.
+ if (value == nullptr) return;
+ const int32_t type_index = value.type_index();
+ if (type_index < TypeIndex::kTVMFFIStaticObjectBegin) {
+ // Primitive — cannot hold an ObjectRef chain entry.
+ return;
+ }
+ switch (type_index) {
+ case TypeIndex::kTVMFFIArray:
+ this->VisitSequence(
+
details::AnyUnsafe::MoveFromAnyAfterCheck<Array<Any>>(std::move(value)));
+ break;
+ case TypeIndex::kTVMFFIList:
+
this->VisitSequence(details::AnyUnsafe::MoveFromAnyAfterCheck<List<Any>>(std::move(value)));
+ break;
+ case TypeIndex::kTVMFFIMap:
+ this->VisitMap(details::AnyUnsafe::MoveFromAnyAfterCheck<Map<Any,
Any>>(std::move(value)));
+ break;
+ case TypeIndex::kTVMFFIDict:
+ this->VisitMap(details::AnyUnsafe::MoveFromAnyAfterCheck<Dict<Any,
Any>>(std::move(value)));
+ break;
+ default:
+ if (type_index >= TypeIndex::kTVMFFIStaticObjectBegin) {
+ ObjectRef obj =
details::AnyUnsafe::MoveFromAnyAfterCheck<ObjectRef>(std::move(value));
+ this->VisitObject(obj);
+ }
+ break;
+ }
+ }
+
+ void VisitObject(const ObjectRef& node) {
+ // Defensive: error path; never throw.
+ if (!node.defined()) return;
+ const TVMFFITypeInfo* type_info = TVMFFIGetTypeInfo(node->type_index());
+ if (type_info == nullptr || type_info->metadata == nullptr) return;
+
+ bool matched_step = num_pattern_step_matched_ < records_.size() &&
+ node.same_as(records_[records_.size() - 1 -
num_pattern_step_matched_]);
+ if (matched_step) {
+ ++num_pattern_step_matched_;
+ if (num_pattern_step_matched_ == records_.size()) {
+ // Full match — materialize the AccessPath and record.
+ results_.push_back(this->MaterializeAccessPath());
+ --num_pattern_step_matched_;
+ return;
+ }
+ }
+
+ this->VisitChildrenFields(node, type_info);
+
+ if (matched_step) --num_pattern_step_matched_;
+ }
+
+ void VisitChildrenFields(ObjectRef node, const TVMFFITypeInfo* type_info) {
+ // Snapshot results_.size() before descent to detect any inner full or
+ // prefix match recorded by a deeper call. If results_ grew, some inner
+ // node was recorded — we do not record again here.
+ size_t results_before = results_.size();
+ reflection::ForEachFieldInfo(type_info, [&](const TVMFFIFieldInfo*
field_info) {
+ reflection::FieldGetter getter(field_info);
+ Any child_val = getter(node);
+ this->PushStep(TempAccessStep::Attr(field_info));
+ this->VisitAny(std::move(child_val));
+ this->PopStep();
+ });
+
+ // Leaf-with-prefix-match: this node contributed a match step, but no
+ // inner result was recorded from its subtree. Record the current node's
+ // path as the best-effort prefix. path_steps_ reflects the path TO this
+ // node (the step leading here was pushed by our caller), so
+ // MaterializeAccessPath() yields the correct prefix path.
+ // Skip when path_steps_ is empty — AccessPath::Root() gives no useful
info.
+ if (allow_prefix_match_ && num_pattern_step_matched_ > 0 &&
+ num_pattern_step_matched_ < records_.size() && !path_steps_.empty() &&
+ results_.size() == results_before) {
+ results_.push_back(this->MaterializeAccessPath());
+ }
+ }
+
+ template <typename SeqType>
+ void VisitSequence(const SeqType& seq) {
+ for (size_t i = 0; i < seq.size(); ++i) {
+ Any item = seq[static_cast<int64_t>(i)];
+ if (item == nullptr) continue;
+ this->PushStep(TempAccessStep::ArrayItem(static_cast<int64_t>(i)));
+ this->VisitAny(std::move(item));
+ this->PopStep();
+ }
+ }
+
+ template <typename MapType>
+ void VisitMap(const MapType& m) {
+ for (const std::pair<Any, Any>& kv : m) {
+ if (kv.first == nullptr || kv.second == nullptr) continue;
+ this->PushStep(TempAccessStep::MapItem(kv.first));
+ this->VisitAny(kv.second);
+ this->PopStep();
+ }
+ }
+
+ /*! \brief Append a TempAccessStep to the descent stack; cache unchanged. */
+ void PushStep(TempAccessStep step) { path_steps_.push_back(std::move(step));
}
+
+ /*! \brief Pop the top TempAccessStep; truncate materialized_paths_ to
match. */
+ void PopStep() {
+ path_steps_.pop_back();
+ if (materialized_paths_.size() > path_steps_.size()) {
+ materialized_paths_.erase(
+ materialized_paths_.begin() +
static_cast<std::ptrdiff_t>(path_steps_.size()),
+ materialized_paths_.end());
+ }
+ }
+
+ // Cache invariant maintained jointly with PushStep / PopStep:
+ // materialized_paths_[k] (when present) is the AccessPath built
+ // from path_steps_[0..k+1] as they currently exist, and
+ // materialized_paths_.size() <= path_steps_.size().
+ //
+ // - PushStep: appends to path_steps_; cache unchanged.
+ // - PopStep: pops path_steps_; truncates cache to match.
+ // - MaterializeAccessPath: extends cache up to path_steps_.size(),
+ // chaining new AccessPath nodes via Extend.
+ //
+ // Lazy materialization avoids per-descent AccessPath allocation;
+ // cache amortizes across consecutive matches with shared prefix
+ // (LCA sharing).
+ /*! \brief Materialize the AccessPath at the current descent depth. */
+ reflection::AccessPath MaterializeAccessPath() {
+ // Root-itself match: matching fired before any descent step was pushed,
+ // so the access path is just Root(). Returning early also avoids reading
+ // back() from an empty materialized_paths_.
+ if (path_steps_.empty()) return reflection::AccessPath::Root();
+ for (size_t idx = materialized_paths_.size(); idx < path_steps_.size();
++idx) {
+ reflection::AccessPath parent =
+ (idx == 0) ? reflection::AccessPath::Root() :
materialized_paths_[idx - 1];
+
materialized_paths_.push_back(parent->Extend(path_steps_[idx].ToAccessStep()));
+ }
+ return materialized_paths_.back();
+ }
+
+ // The visit error context whose pattern we're matching against.
+ VisitErrorContext context_;
+ // When true, record prefix matches at leaves (partial pattern match).
+ bool allow_prefix_match_;
+ // Normalized breadcrumb pattern derived from context_->reverse_visit_pattern
+ // by dropping null/undefined entries and collapsing consecutive duplicates.
+ // See the constructor for rationale.
+ std::vector<ObjectRef> records_;
+ // Count of pattern entries matched so far (root-closest first). Incremented
on
+ // match, decremented on unwind; full match when equal to pattern size.
+ size_t num_pattern_step_matched_{0};
+ // Current descent path — entries pushed on field/index/key descent, popped
on unwind.
+ std::vector<TempAccessStep> path_steps_;
+ // Lazy cache of materialized AccessPath nodes, parallel to path_steps_.
+ // materialized_paths_[k] corresponds to path_steps_[0..k+1] as they
currently
+ // stand. Extended on match; truncated by PopStep. size <=
path_steps_.size().
+ std::vector<reflection::AccessPath> materialized_paths_;
+ // Recorded full-match paths.
+ std::vector<reflection::AccessPath> results_;
+};
+
+// ---------------------------------------------------------------------------
+// VisitErrorContext::FindAccessPaths
+// ---------------------------------------------------------------------------
+
+TVM_FFI_COLD_CODE
+Array<reflection::AccessPath> VisitErrorContext::FindAccessPaths(
+ const ObjectRef& root, const VisitErrorContext& visit_context, bool
allow_prefix_match) {
+ VisitErrorAccessPathFinder finder(visit_context, allow_prefix_match);
+ return finder.Find(root);
+}
+
+// ---------------------------------------------------------------------------
+// Registration
+// ---------------------------------------------------------------------------
+
+TVM_FFI_STATIC_INIT_BLOCK() {
+ namespace refl = tvm::ffi::reflection;
+ refl::ObjectDef<VisitErrorContextObj>()
+ .def_ro("reverse_visit_pattern",
&VisitErrorContextObj::reverse_visit_pattern)
+ .def_ro("prev_error_context", &VisitErrorContextObj::prev_error_context);
+ refl::GlobalDef().def("ffi.VisitErrorContext.FindAccessPaths",
+ &VisitErrorContext::FindAccessPaths);
+}
+
+} // namespace ffi
+} // namespace tvm
diff --git a/tests/cpp/extra/test_visit_error_context.cc
b/tests/cpp/extra/test_visit_error_context.cc
new file mode 100644
index 0000000..91c3361
--- /dev/null
+++ b/tests/cpp/extra/test_visit_error_context.cc
@@ -0,0 +1,652 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+#include <gtest/gtest.h>
+#include <tvm/ffi/container/array.h>
+#include <tvm/ffi/container/map.h>
+#include <tvm/ffi/error.h>
+#include <tvm/ffi/extra/structural_equal.h>
+#include <tvm/ffi/extra/visit_error_context.h>
+#include <tvm/ffi/object.h>
+#include <tvm/ffi/reflection/access_path.h>
+#include <tvm/ffi/reflection/registry.h>
+#include <tvm/ffi/string.h>
+
+namespace {
+
+using namespace tvm::ffi;
+namespace refl = tvm::ffi::reflection;
+
+// ---------------------------------------------------------------------------
+// Test fixture: TPair — a minimal two-field Object with lhs and rhs slots.
+//
+// Covers kAttr AccessKind via its named fields.
+// Array<ObjectRef> and Map<Any, Any> are used alongside TPair in individual
+// tests to cover kArrayItem and kMapItem.
+// ---------------------------------------------------------------------------
+
+class TPair : public Object {
+ public:
+ ObjectRef lhs;
+ ObjectRef rhs;
+
+ static constexpr TVMFFISEqHashKind _type_s_eq_hash_kind =
kTVMFFISEqHashKindTreeNode;
+ TVM_FFI_DECLARE_OBJECT_INFO_FINAL("test.TPair", TPair, Object);
+};
+
+class TPairRef : public ObjectRef {
+ public:
+ TPairRef(ObjectRef lhs, ObjectRef rhs) {
+ ObjectPtr<TPair> n = make_object<TPair>();
+ n->lhs = std::move(lhs);
+ n->rhs = std::move(rhs);
+ data_ = std::move(n);
+ }
+ TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE(TPairRef, ObjectRef, TPair);
+};
+
+TVM_FFI_STATIC_INIT_BLOCK() {
+ namespace r = tvm::ffi::reflection;
+ r::ObjectDef<TPair>().def_ro("lhs", &TPair::lhs).def_ro("rhs", &TPair::rhs);
+}
+
+// ---------------------------------------------------------------------------
+// Helper: Visit — recursively walks a tree and throws when it reaches
+// throw_target, wrapping each recursion level with the visit
+// macros so the error chain accumulates on unwind.
+// ---------------------------------------------------------------------------
+
+void Visit(const ObjectRef& node, const ObjectRef& throw_target);
+
+void Visit(const ObjectRef& node, const ObjectRef& throw_target) {
+ TVM_FFI_VISIT_BEGIN();
+
+ if (node.same_as(throw_target)) {
+ TVM_FFI_THROW(ValueError) << "boom";
+ } else if (Optional<TPairRef> pair = node.as<TPairRef>()) {
+ Visit(pair.value()->lhs, throw_target);
+ Visit(pair.value()->rhs, throw_target);
+ } else if (Optional<Array<ObjectRef>> arr = node.as<Array<ObjectRef>>()) {
+ for (const ObjectRef& child : *arr) {
+ Visit(child, throw_target);
+ }
+ } else if (Optional<Map<Any, Any>> m = node.as<Map<Any, Any>>()) {
+ for (const std::pair<Any, Any>& kv : *m) {
+ Visit(kv.second.cast<ObjectRef>(), throw_target);
+ }
+ }
+
+ TVM_FFI_VISIT_END(node);
+}
+
+// ===========================================================================
+// Layer A — Macro → Chain
+//
+// Verify that TVM_FFI_VISIT_BEGIN/_END correctly builds the
+// reverse_visit_pattern as an exception unwinds through visit levels.
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// MacroBuildsChain: throw deep in a two-level visit; confirm the chain
+// records nodes in innermost-first order (throw site first, root last).
+// ---------------------------------------------------------------------------
+
+TEST(VisitErrorContext, MacroBuildsChain) {
+ // Tree: root = (lhs=leaf, rhs=empty)
+ // Visiting root -> descends into leaf -> throws.
+ Array<ObjectRef> empty;
+ TPairRef leaf(empty, empty);
+ TPairRef root(leaf, empty);
+
+ Error caught("RuntimeError", "", "");
+ bool did_catch = false;
+ try {
+ Visit(root, leaf);
+ } catch (Error& err) {
+ caught = err;
+ did_catch = true;
+ }
+
+ EXPECT_TRUE(did_catch);
+
+ Optional<VisitErrorContext> visit_context =
VisitErrorContext::TryGetFromError(caught);
+ ASSERT_TRUE(visit_context.has_value());
+
+ // reverse_visit_pattern is innermost-first: leaf pushed first (inner catch
+ // fires first during unwind), root pushed last.
+ const List<ObjectRef>& chain = visit_context.value()->reverse_visit_pattern;
+ EXPECT_GE(chain.size(), 2u);
+ // Innermost entry is leaf (the throw site).
+ EXPECT_TRUE(chain[0].same_as(leaf));
+ // Outermost entry is root.
+ EXPECT_TRUE(chain[chain.size() - 1].same_as(root));
+
+ // Verify order structurally: [leaf, ..., root].
+ List<ObjectRef> expected_chain = {leaf, root};
+ EXPECT_TRUE(StructuralEqual::Equal(expected_chain, chain));
+}
+
+// ---------------------------------------------------------------------------
+// MacroPreExistingPayloadWrap: when UpdateVisitErrorContext is called on
+// an error that already has a non-context extra_context payload, the existing
+// payload is stashed in prev_error_context and the visit chain starts
+// fresh. Subsequent pushes append to the same context object;
prev_error_context
+// is not modified.
+// ---------------------------------------------------------------------------
+
+TEST(VisitErrorContext, MacroPreExistingPayloadWrap) {
+ // Stand-in for a pre-existing extra_context payload.
+ Array<ObjectRef> empty;
+ TPairRef existing_payload(empty, empty);
+ TPairRef leaf(empty, empty);
+ TPairRef root(leaf, empty);
+
+ // Construct an error that already carries a non-context extra_context
payload.
+ Error err("RuntimeError", "test", "", std::nullopt,
std::optional<ObjectRef>(existing_payload));
+ ASSERT_TRUE(err.extra_context().has_value());
+
+ // First push: existing payload is not a VisitErrorContext → wrapped.
+ tvm::ffi::details::UpdateVisitErrorContext(err, leaf);
+
+ Optional<VisitErrorContext> visit_context =
VisitErrorContext::TryGetFromError(err);
+ ASSERT_TRUE(visit_context.has_value());
+ ASSERT_EQ(visit_context.value()->reverse_visit_pattern.size(), 1u);
+ EXPECT_TRUE(visit_context.value()->reverse_visit_pattern[0].same_as(leaf));
+ ASSERT_TRUE(visit_context.value()->prev_error_context.has_value());
+
EXPECT_TRUE(visit_context.value()->prev_error_context.value().same_as(existing_payload));
+
+ // Second push: context already exists → node appended; prev_error_context
unchanged.
+ tvm::ffi::details::UpdateVisitErrorContext(err, root);
+
+ Optional<VisitErrorContext> visit_context_after =
VisitErrorContext::TryGetFromError(err);
+ ASSERT_TRUE(visit_context_after.has_value());
+
+ List<ObjectRef> expected_chain = {leaf, root};
+ EXPECT_TRUE(
+ StructuralEqual::Equal(expected_chain,
visit_context_after.value()->reverse_visit_pattern));
+
+ ASSERT_TRUE(visit_context_after.value()->prev_error_context.has_value());
+
EXPECT_TRUE(visit_context_after.value()->prev_error_context.value().same_as(existing_payload));
+}
+
+// ---------------------------------------------------------------------------
+// MacroThrowBuildsChain: TVM_FFI_VISIT_THROW seeds the
+// VisitErrorContext with the throw-site node directly (no separate
+// UpdateVisitErrorContext call). Enclosing VISIT_BEGIN/END frames then
+// append parent nodes on rethrow.
+//
+// Sub-scenario A — standalone: THROW outside any BEGIN/END wrapper produces
+// a context whose reverse_visit_pattern is exactly [throw_site].
+//
+// Sub-scenario B — nested under BEGIN/END: when THROW fires inside a
+// VISIT_BEGIN/END(root) pair, the enclosing END appends root. The throw site
+// itself is recorded by THROW. If the throw_target happens to equal `node`
+// at the surrounding level (a common case), FindAccessPaths' cleanup
+// collapses the consecutive duplicate; tested separately in
+// FindAccessPaths.RecordsCleanup.
+// ---------------------------------------------------------------------------
+
+TEST(VisitErrorContext, MacroThrowBuildsChain) {
+ Array<ObjectRef> empty;
+
+ // Sub-scenario A — standalone THROW (no surrounding BEGIN/END).
+ {
+ TPairRef throw_site(empty, empty);
+ bool did_catch = false;
+ Error caught("placeholder", "", "");
+ try {
+ TVM_FFI_VISIT_THROW(ValueError, throw_site) << "boom";
+ } catch (Error& err) {
+ caught = err;
+ did_catch = true;
+ }
+ ASSERT_TRUE(did_catch);
+
+ Optional<VisitErrorContext> visit_context =
VisitErrorContext::TryGetFromError(caught);
+ ASSERT_TRUE(visit_context.has_value());
+ const List<ObjectRef>& chain =
visit_context.value()->reverse_visit_pattern;
+ ASSERT_EQ(chain.size(), 1u);
+ EXPECT_TRUE(chain[0].same_as(throw_site));
+ }
+
+ // Sub-scenario B — THROW inside a BEGIN/END(root) wrapper.
+ {
+ TPairRef throw_site(empty, empty);
+ TPairRef root(throw_site, empty);
+ bool did_catch = false;
+ Error caught("placeholder", "", "");
+ try {
+ TVM_FFI_VISIT_BEGIN();
+ TVM_FFI_VISIT_THROW(ValueError, throw_site) << "boom";
+ TVM_FFI_VISIT_END(root);
+ } catch (Error& err) {
+ caught = err;
+ did_catch = true;
+ }
+ ASSERT_TRUE(did_catch);
+
+ Optional<VisitErrorContext> visit_context =
VisitErrorContext::TryGetFromError(caught);
+ ASSERT_TRUE(visit_context.has_value());
+ const List<ObjectRef>& chain =
visit_context.value()->reverse_visit_pattern;
+ // THROW seeds with throw_site; END appends root.
+ List<ObjectRef> expected = {throw_site, root};
+ EXPECT_TRUE(StructuralEqual::Equal(expected, chain));
+ }
+}
+
+// ===========================================================================
+// Layer B — Chain → AccessPaths
+//
+// Verify that FindAccessPaths(root, ctx) resolves a manually-constructed
+// VisitErrorContext against a tree, producing AccessPaths that describe
+// where matched nodes live in the tree.
+//
+// Each test:
+// 1. Builds a root tree.
+// 2. Constructs a VisitErrorContext with a specific reverse_visit_pattern.
+// 3. Calls FindAccessPaths(root, ctx).
+// 4. Asserts on the result using StructuralEqual::Equal on AccessPath
+// (mirrors the pattern used in test_structural_equal_hash.cc).
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// BasicMatch: unambiguous single-node path via two Attr steps; and CSE
+// multi-match when the same pointer appears in two sibling slots.
+//
+// Sub-scenario A — SingleMatch:
+// Tree: root.lhs = mid, mid.lhs = leaf
+// Pattern: [leaf, mid, root] (innermost-first)
+// Expected path: Root->Attr("lhs")->Attr("lhs")
+//
+// Sub-scenario B — CSEMultiMatch:
+// Tree: root.lhs = shared, root.rhs = shared (identical pointer)
+// Pattern: [shared, root]
+// Expected: two paths, one for each slot (Attr("lhs") and Attr("rhs")).
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, BasicMatch) {
+ Array<ObjectRef> empty;
+
+ // Sub-scenario A: single unambiguous path.
+ {
+ TPairRef leaf(empty, empty);
+ TPairRef mid(leaf, empty);
+ TPairRef root(mid, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{leaf, mid, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+
+ refl::AccessPath expected =
refl::AccessPath::Root()->Attr("lhs")->Attr("lhs");
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+
+ // Sub-scenario B: same pointer in two slots → two paths.
+ {
+ TPairRef shared(empty, empty);
+ TPairRef root(shared, shared);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{shared, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 2u);
+
+ refl::AccessPath expected_lhs = refl::AccessPath::Root()->Attr("lhs");
+ refl::AccessPath expected_rhs = refl::AccessPath::Root()->Attr("rhs");
+ bool found_lhs = false;
+ bool found_rhs = false;
+ for (const refl::AccessPath& p : paths) {
+ if (StructuralEqual::Equal(p, expected_lhs)) found_lhs = true;
+ if (StructuralEqual::Equal(p, expected_rhs)) found_rhs = true;
+ }
+ EXPECT_TRUE(found_lhs);
+ EXPECT_TRUE(found_rhs);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// AccessKindCoverage: exercises Attr (TPair fields), ArrayItem
+// (Array<ObjectRef>), and MapItem (Map<Any, Any>) access kinds.
+//
+// Sub-scenario A — Attr (kAttr):
+// Tree: root.lhs = mid, mid.lhs = leaf
+// Pattern: [leaf, root]
+// Expected path: Root->Attr("lhs")->Attr("lhs")
+//
+// Sub-scenario B — ArrayItem (kArrayItem):
+// Tree: root.lhs = [leaf1, leaf2] (Array in ObjectRef slot)
+// Pattern: [leaf1, root]
+// Expected path: Root->Attr("lhs")->ArrayItem(0)
+//
+// Sub-scenario C — MapItem (kMapItem):
+// Tree: root.lhs = {"key" -> target} (Map in ObjectRef slot)
+// Pattern: [target, root]
+// Expected path: Root->Attr("lhs")->MapItem("key")
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, AccessKindCoverage) {
+ Array<ObjectRef> empty;
+
+ // Sub-scenario A: Attr steps.
+ {
+ TPairRef leaf(empty, empty);
+ TPairRef mid(leaf, empty);
+ TPairRef root(mid, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{leaf, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+
+ refl::AccessPath expected =
refl::AccessPath::Root()->Attr("lhs")->Attr("lhs");
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+
+ // Sub-scenario B: ArrayItem step.
+ {
+ TPairRef leaf1(empty, empty);
+ TPairRef leaf2(empty, empty);
+ Array<ObjectRef> arr = {leaf1, leaf2};
+ TPairRef root(arr, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{leaf1, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+
+ refl::AccessPath expected =
refl::AccessPath::Root()->Attr("lhs")->ArrayItem(0);
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+
+ // Sub-scenario C: MapItem step.
+ {
+ TPairRef target(empty, empty);
+ Map<Any, Any> m;
+ m.Set(String("key"), target);
+ TPairRef root(m, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{target, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+
+ refl::AccessPath expected =
refl::AccessPath::Root()->Attr("lhs")->MapItem(String("key"));
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SparsePatternAnchors: demonstrates that intermediate breadcrumb anchors in
+// the pattern disambiguate when an innermost target appears in multiple
branches.
+//
+// Tree layout:
+// root
+// ├─ branch_a: tpa → m_inner_a (m_inner shared, no m_outer ancestor)
+// └─ branch_b: tpb → m_outer → tpc → m_inner_b (same m_inner pointer)
+//
+// m_inner is a POINTER-IDENTICAL ObjectRef (ref-count shared) used as both
+// m_inner_a (under branch_a) and m_inner_b (under branch_b via m_outer).
+// m_outer appears only under branch_b.
+//
+// Scenario 1: pattern with only innermost — 2 candidate paths.
+// Pattern: [m_inner, root]
+// Expected: paths.size() == 2
+//
+// Scenario 2: anchor narrows to branch_b only.
+// Pattern: [m_inner, m_outer, root]
+// Expected: paths.size() == 1, path through branch_b
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, SparsePatternAnchors) {
+ Array<ObjectRef> empty;
+
+ // Shared inner node — identical pointer in both branches.
+ TPairRef m_inner(empty, empty);
+
+ // branch_a: tpa.lhs = m_inner (no m_outer ancestor)
+ TPairRef tpa(m_inner, empty);
+
+ // branch_b: tpb.lhs = m_outer, m_outer.lhs = tpc, tpc.lhs = m_inner
+ TPairRef tpc(m_inner, empty);
+ TPairRef m_outer(tpc, empty);
+ TPairRef tpb(m_outer, empty);
+
+ // root: lhs = tpa (branch_a), rhs = tpb (branch_b)
+ TPairRef root(tpa, tpb);
+
+ // Scenario 1: pattern = [m_inner, root] → 2 paths (one per branch).
+ {
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{m_inner, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ EXPECT_EQ(paths.size(), 2u);
+ }
+
+ // Scenario 2: pattern = [m_inner, m_outer, root] → 1 path through branch_b.
+ {
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{m_inner, m_outer, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+
+ // Path must go through branch_b (rhs), then m_outer (lhs), then tpc
(lhs), then m_inner (lhs).
+ refl::AccessPath expected =
+
refl::AccessPath::Root()->Attr("rhs")->Attr("lhs")->Attr("lhs")->Attr("lhs");
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// PartialChain: strict mode returns nothing when the innermost pattern entry
+// is unreachable; prefix-match mode returns the deepest matched prefix path.
+//
+// Sub-scenario A — strict (allow_prefix_match=false):
+// Pattern: [unreachable, child, root]
+// Expected: 0 results (unreachable breaks full match)
+//
+// Sub-scenario B — prefix match (allow_prefix_match=true):
+// Same pattern, same tree.
+// Expected: at least one result at the path to child (Root->Attr("lhs")).
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, PartialChain) {
+ Array<ObjectRef> empty;
+ // unreachable is not present anywhere in the tree below root.
+ TPairRef unreachable(empty, empty);
+ TPairRef child(empty, empty);
+ TPairRef root(child, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{unreachable, child, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ // Sub-scenario A: strict mode — no full match.
+ {
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ EXPECT_EQ(paths.size(), 0u);
+ }
+
+ // Sub-scenario B: prefix-match mode — path to child reported.
+ {
+ Array<refl::AccessPath> paths =
+ VisitErrorContext::FindAccessPaths(root, ctx,
/*allow_prefix_match=*/true);
+ ASSERT_GE(paths.size(), 1u);
+
+ refl::AccessPath expected = refl::AccessPath::Root()->Attr("lhs");
+ bool found_expected = false;
+ for (const refl::AccessPath& p : paths) {
+ if (StructuralEqual::Equal(p, expected)) {
+ found_expected = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(found_expected);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// EdgeCases: empty pattern produces no results; null entries in the pattern
+// never match any live node and do not crash.
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, EdgeCases) {
+ Array<ObjectRef> empty;
+
+ // Sub-scenario A: empty pattern.
+ {
+ TPairRef root(empty, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ // reverse_visit_pattern left empty.
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ EXPECT_EQ(paths.size(), 0u);
+ }
+
+ // Sub-scenario A2: root-itself match — pattern is [root], the throw fired
+ // before any descent. Expected: a single AccessPath equal to Root() (no
+ // crash from materializing on an empty descent stack).
+ {
+ TPairRef root(empty, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+ EXPECT_TRUE(StructuralEqual::Equal(refl::AccessPath::Root(), paths[0]));
+ }
+
+ // Sub-scenario B: null entries in pattern.
+ {
+ TPairRef root(empty, empty);
+
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ObjectRef null_ref;
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{null_ref, null_ref};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths;
+ ASSERT_NO_THROW({ paths = VisitErrorContext::FindAccessPaths(root, ctx);
});
+ EXPECT_EQ(paths.size(), 0u);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// RecordsCleanup: FindAccessPaths normalizes reverse_visit_pattern before
+// matching by (a) dropping null entries and (b) collapsing runs of
+// consecutive duplicates. Both arise naturally — null from stale/torn-down
+// nodes; duplicates when TVM_FFI_VISIT_THROW(node) is wrapped by
+// a TVM_FFI_VISIT_END(node) at the same level. Without cleanup
+// the redundant frame would over-constrain the match and yield zero paths.
+// ---------------------------------------------------------------------------
+
+TEST(FindAccessPaths, RecordsCleanup) {
+ Array<ObjectRef> empty;
+ TPairRef leaf(empty, empty);
+ TPairRef root(leaf, empty);
+ ObjectRef null_ref;
+
+ refl::AccessPath expected = refl::AccessPath::Root()->Attr("lhs");
+
+ // Sub-scenario A — consecutive duplicates collapse.
+ // Raw pattern: [leaf, leaf, root, root]; effective: [leaf, root].
+ {
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{leaf, leaf, root, root};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+
+ // Sub-scenario B — nulls are skipped, surrounding entries still match.
+ // Raw pattern: [null, leaf, null, root, null]; effective: [leaf, root].
+ {
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{null_ref, leaf, null_ref,
root, null_ref};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+
+ // Sub-scenario C — combined: nulls plus dup-consecutive.
+ // Raw pattern: [leaf, null, leaf, root, root, null]; effective: [leaf,
root].
+ {
+ ObjectPtr<VisitErrorContextObj> ctx_obj =
make_object<VisitErrorContextObj>();
+ ctx_obj->reverse_visit_pattern = List<ObjectRef>{leaf, null_ref, leaf,
root, root, null_ref};
+ VisitErrorContext ctx(std::move(ctx_obj));
+
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
ctx);
+ ASSERT_EQ(paths.size(), 1u);
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// TryGetFromError: returns NullOpt when absent, the context when present.
+// Composes correctly with FindAccessPaths.
+// ---------------------------------------------------------------------------
+
+TEST(VisitErrorContext, TryGetFromError) {
+ Array<ObjectRef> empty;
+ TPairRef leaf(empty, empty);
+ TPairRef root(leaf, empty);
+
+ Error err("RuntimeError", "test", "");
+
+ // No context attached → TryGetFromError returns NullOpt.
+ Optional<VisitErrorContext> no_context =
VisitErrorContext::TryGetFromError(err);
+ EXPECT_FALSE(no_context.has_value());
+
+ // Attach context via UpdateVisitErrorContext.
+ tvm::ffi::details::UpdateVisitErrorContext(err, leaf);
+ tvm::ffi::details::UpdateVisitErrorContext(err, root);
+
+ Optional<VisitErrorContext> visit_context =
VisitErrorContext::TryGetFromError(err);
+ ASSERT_TRUE(visit_context.has_value());
+
+ // Compose TryGetFromError + FindAccessPaths → should resolve to root.lhs.
+ Array<refl::AccessPath> paths = VisitErrorContext::FindAccessPaths(root,
visit_context.value());
+ ASSERT_EQ(paths.size(), 1u);
+
+ refl::AccessPath expected = refl::AccessPath::Root()->Attr("lhs");
+ EXPECT_TRUE(StructuralEqual::Equal(expected, paths[0]));
+}
+
+} // namespace