You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/07/31 16:51:19 UTC

(groovy) branch GROOVY-12191 updated: GROOVY-12191: Drop hierarchy SwitchPoint fan-out for stock metaclasses

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12191
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/GROOVY-12191 by this push:
     new c81e62bcca GROOVY-12191: Drop hierarchy SwitchPoint fan-out for stock metaclasses
c81e62bcca is described below

commit c81e62bccaad66e6d4816884ea40807d3a5ab199
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Aug 1 01:50:00 2026 +0900

    GROOVY-12191: Drop hierarchy SwitchPoint fan-out for stock metaclasses
---
 src/main/java/groovy/lang/MetaClassImpl.java       |  14 +-
 .../groovy/runtime/indy/ClassHierarchyIndex.java   | 169 -------
 .../groovy/runtime/indy/IndyInvalidation.java      | 486 ++++++---------------
 .../runtime/indy/SwitchPointInvalidator.java       |  12 +-
 .../apache/groovy/runtime/indy/package-info.java   |  24 +-
 .../org/codehaus/groovy/reflection/ClassInfo.java  |  42 +-
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java |  13 +-
 src/test/groovy/bugs/Groovy12191.groovy            | 309 ++++++++-----
 .../runtime/indy/ClassHierarchyIndexTest.groovy    | 191 --------
 .../runtime/indy/IndyInvalidationTest.groovy       | 363 ++++++---------
 .../vmplugin/v8/IndyScopedSwitchPointTest.groovy   |   2 +-
 .../runtime/indy/IndyInvalidationStatsProbe.java   |   1 +
 subprojects/performance/README.adoc                |  11 +-
 .../groovy/bench/ScopedInvalidationBench.groovy    |  28 +-
 14 files changed, 546 insertions(+), 1119 deletions(-)

diff --git a/src/main/java/groovy/lang/MetaClassImpl.java b/src/main/java/groovy/lang/MetaClassImpl.java
index 5db08394e8..2bc9e837c0 100644
--- a/src/main/java/groovy/lang/MetaClassImpl.java
+++ b/src/main/java/groovy/lang/MetaClassImpl.java
@@ -3903,7 +3903,9 @@ public class MetaClassImpl implements MetaClass, MutableMetaClass {
     }
 
     /**
-     * Searches the class hierarchy for a mutable meta-class bean property.
+     * Searches the class hierarchy for a mutable meta-class bean property on the
+     * missing-property path. Linked miss sites re-enter this walk live (no
+     * SwitchPoint fan-out); see {@link org.apache.groovy.runtime.indy.IndyInvalidation}.
      *
      * @param propertyName the property name
      * @param theClass the class to start searching from
@@ -3983,13 +3985,9 @@ public class MetaClassImpl implements MetaClass, MutableMetaClass {
      * {@code Object.metaClass.foo = …} / parent EMC methods become visible on
      * subclass receivers without giving every subtype its own EMC.
      * <p>
-     * <b>6.0 decision (GROOVY-12191):</b> {@code MetaClassImpl} continues to
-     * observe ancestor MetaClass state here. Making {@code MetaClassImpl} immune
-     * to other MetaClasses would remove this walk and the construction-time
-     * snapshot of ancestor expando methods, but would also break the inheritance
-     * patterns above. Indy hierarchy SwitchPoint fan-out exists solely so
-     * already-linked subtype sites re-select when this walk’s result can change
-     * while the receiver’s own MetaClass stays unchanged.
+     * <b>6.0 (GROOVY-12191):</b> this walk is retained. Linked miss sites re-enter
+     * it live without SwitchPoint fan-out — see
+     * {@link org.apache.groovy.runtime.indy.IndyInvalidation}.
      *
      * @param instanceKlazz the runtime receiver class
      * @param methodName the method name
diff --git a/src/main/java/org/apache/groovy/runtime/indy/ClassHierarchyIndex.java b/src/main/java/org/apache/groovy/runtime/indy/ClassHierarchyIndex.java
deleted file mode 100644
index 7a4606a3d1..0000000000
--- a/src/main/java/org/apache/groovy/runtime/indy/ClassHierarchyIndex.java
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- *  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.
- */
-package org.apache.groovy.runtime.indy;
-
-import groovy.transform.Internal;
-import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
-import org.codehaus.groovy.reflection.ClassInfo;
-import org.codehaus.groovy.util.ManagedConcurrentLinkedQueue;
-import org.codehaus.groovy.util.ReferenceBundle;
-
-import java.io.Serializable;
-import java.lang.reflect.Array;
-import java.util.Collection;
-import java.util.LinkedHashSet;
-import java.util.Set;
-
-/**
- * Ancestor → descendant {@link ClassInfo} index for scoped SwitchPoint fan-out
- * (GROOVY-12191).
- * <p>
- * Built once per {@link ClassInfo} at construction: each type is registered under
- * every MOP-relevant strict supertype (superclass chain, transitive interfaces,
- * and the full JLS array-covariance lattice). Hierarchy invalidation for type
- * {@code T} then walks only the descendants of {@code T} —
- * {@code O(|subtypes of T|)} — instead of scanning every loaded
- * {@code ClassInfo} with {@code isAssignableFrom}.
- * <p>
- * The index is consulted when MetaClass-aware policy chooses hierarchy fan-out
- * (EMC install/update, global EMC, interface / array MetaClass changes). It is
- * not modelling a shared {@code MetaClassImpl} table — each class still has its
- * own MetaClass instance domain (weak identity map in {@link IndyInvalidation}).
- * Subtype domains re-link so the live <em>missing-method</em> hierarchy walk
- * can observe ancestor expando methods. Pure {@code MetaClassImpl} replaces do
- * not walk this index (exact-class only).
- * <p>
- * Keys and values are weakly held so the index does not pin classes or
- * {@code ClassInfo} instances after they become unreachable.
- * <p>
- * <strong>Not a public API.</strong> Wired only from {@link ClassInfo} and
- * {@link IndyInvalidation}.
- *
- * @since 6.0.0
- */
-@Internal
-public final class ClassHierarchyIndex {
-
-    private static final ReferenceBundle WEAK = ReferenceBundle.getWeakBundle();
-
-    /**
-     * Ancestor class (weak, identity) → weakly-reachable descendant ClassInfos.
-     * A type is never registered under itself.
-     */
-    private static final ManagedIdentityConcurrentMap<Class<?>, ManagedConcurrentLinkedQueue<ClassInfo>> BY_ANCESTOR =
-            new ManagedIdentityConcurrentMap<>(256);
-
-    private ClassHierarchyIndex() {
-    }
-
-    /**
-     * Indexes {@code info} under every strict supertype of its class.
-     * Called exactly once from the {@link ClassInfo} constructor while the
-     * {@link Class} is strongly reachable.
-     *
-     * @param info the newly constructed ClassInfo
-     */
-    @Internal
-    public static void register(final ClassInfo info) {
-        Class<?> type = info.getTheClass();
-        if (type == null) {
-            return;
-        }
-        Set<Class<?>> ancestors = new LinkedHashSet<>();
-        collectStrictSupertypes(type, ancestors);
-        for (Class<?> ancestor : ancestors) {
-            addUnder(ancestor, info);
-        }
-    }
-
-    private static void addUnder(final Class<?> ancestor, final ClassInfo info) {
-        ManagedConcurrentLinkedQueue<ClassInfo> queue =
-                BY_ANCESTOR.applyIfAbsent(ancestor, k -> new ManagedConcurrentLinkedQueue<>(WEAK));
-        queue.add(info);
-    }
-
-    /**
-     * Appends every ClassInfo registered as a descendant of {@code ancestor}
-     * (not including {@code ancestor} itself) to {@code out}. Empty when no
-     * subtypes have been indexed (e.g. finals, primitives, or unused parents).
-     *
-     * @param ancestor the root of the fan-out
-     * @param out      destination collection
-     */
-    @Internal
-    public static void collectDescendants(final Class<?> ancestor, final Collection<ClassInfo> out) {
-        ManagedConcurrentLinkedQueue<ClassInfo> queue = BY_ANCESTOR.get(ancestor);
-        if (queue == null) {
-            return;
-        }
-        for (ClassInfo info : queue) {
-            // Iterator of ManagedConcurrentLinkedQueue skips cleared weak refs.
-            out.add(info);
-        }
-    }
-
-    /**
-     * Collects every strict supertype {@code S} of {@code type} such that
-     * {@code S.isAssignableFrom(type) && S != type}, computed structurally from
-     * the type's hierarchy (no global ClassInfo scan).
-     * <p>
-     * Array types follow the JLS covariance lattice: for component {@code C},
-     * every supertype {@code S} of {@code C} (including {@code C}) yields
-     * {@code S[]} as a supertype of {@code C[]}, plus {@code Object},
-     * {@code Cloneable}, and {@code Serializable}.
-     *
-     * @param type the type whose strict supertypes are collected
-     * @param out  destination set
-     */
-    static void collectStrictSupertypes(final Class<?> type, final Set<Class<?>> out) {
-        if (type == null || type == Object.class || type.isPrimitive()) {
-            return;
-        }
-        if (type.isArray()) {
-            out.add(Object.class);
-            out.add(Cloneable.class);
-            out.add(Serializable.class);
-            Class<?> component = type.getComponentType();
-            // {component} ∪ strict-supers(component) — each lifted to a 1-D array.
-            Set<Class<?>> componentLattice = new LinkedHashSet<>();
-            componentLattice.add(component);
-            collectStrictSupertypes(component, componentLattice);
-            for (Class<?> componentType : componentLattice) {
-                Class<?> arrayOf = Array.newInstance(componentType, 0).getClass();
-                if (arrayOf != type) {
-                    out.add(arrayOf);
-                }
-            }
-            return;
-        }
-        Class<?> superclass = type.getSuperclass();
-        if (superclass != null) {
-            out.add(superclass);
-            collectStrictSupertypes(superclass, out);
-        } else if (type.isInterface()) {
-            // Class.getSuperclass() is null for interfaces; assignability still
-            // treats Object as a supertype (Object.isAssignableFrom(iface) == true).
-            out.add(Object.class);
-        }
-        for (Class<?> iface : type.getInterfaces()) {
-            out.add(iface);
-            collectStrictSupertypes(iface, out);
-        }
-    }
-}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
index f3c4d274f2..c45793f3e7 100644
--- a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
+++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
@@ -20,14 +20,9 @@ package org.apache.groovy.runtime.indy;
 
 import groovy.lang.AdaptingMetaClass;
 import groovy.lang.DelegatingMetaClass;
-import groovy.lang.ExpandoMetaClass;
-import groovy.lang.ExpandoMetaClassCreationHandle;
-import groovy.lang.GroovySystem;
 import groovy.lang.MetaClass;
 import groovy.lang.MetaClassImpl;
-import groovy.lang.MetaClassRegistry;
 import groovy.lang.MetaClassRegistryChangeEvent;
-import groovy.lang.MutableMetaClass;
 import org.apache.groovy.util.SystemUtil;
 import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
 import org.codehaus.groovy.reflection.ClassInfo;
@@ -46,137 +41,33 @@ import java.util.logging.Logger;
  * Scoped invokedynamic {@link SwitchPoint} invalidation for the Groovy MOP
  * (GROOVY-12191).
  * <p>
- * <b>Model (single domain):</b> one SwitchPoint domain per
- * <em>{@link MetaClass} instance</em>, held in a weak identity map in this
- * class — not on {@link ClassInfo}, and not as fields on {@link MetaClassImpl}.
- * Every MetaClass kind (EMC, plain {@code MetaClassImpl}, custom) uses the same
- * mechanism. Monomorphic indy sites install the SwitchPoint of the class-level
- * MetaClass observed at link time — a single {@code guardWithTest} — while
- * avoiding process-wide deopt on unrelated MetaClass churn.
- * <p>
- * When no class-level MetaClass is installed yet (e.g. link during
- * {@code defineClass} before nested types exist), sites use a
- * <em>pending</em> SwitchPoint on {@link ClassInfo} (live domain, retired on
- * first MetaClass install). An already-invalid SwitchPoint cannot be used:
- * monomorphic caching would re-enter fallback forever. After a MetaClass
- * exists, only that instance’s domain is used — never both. Per-instance
- * MetaClass still forces uncacheable PIC entries ({@code canSetTarget=false}).
- *
- * <h2>Why hierarchy fan-out still exists (and what it is not)</h2>
- * <p>
- * Hierarchy fan-out is <em>not</em> “{@code MetaClassImpl} shares one method table
- * up the hierarchy”. Each class still has its own MetaClass domain. Fan-out
- * exists solely because <em>selection can observe ancestor MetaClass state</em>
- * without the receiver’s MetaClass changing:
- * <ol>
- *   <li><b>Missing-method path only (live) — the only case that requires
- *       parent→child SwitchPoint fan-out:</b>
- *       {@code MetaClassImpl.findMethodInClassHierarchy} returns immediately
- *       unless some strong MetaClass in the receiver hierarchy is a
- *       <em>modified</em> {@link MutableMetaClass} (EMC is the common case).
- *       When that gate opens, a previously linked miss on a subtype can become
- *       a hit (parent adds method), and a previously linked hierarchy hit can
- *       become a miss (parent EMC removed). Present methods on a child keep
- *       winning for applicable arguments after re-link (the walk does not
- *       rebuild MetaClass tables).</li>
- *   <li><b>Construction-time snapshot (MetaClassImpl init) — not fixed by SP:</b>
- *       when a {@code MetaClassImpl} is first built <em>after</em> an ancestor
- *       already carries expando methods, those methods can be copied into the
- *       child’s method index. A sibling whose MetaClass was built <em>before</em>
- *       the ancestor change keeps the older view. SwitchPoint retirement
- *       re-links against the <em>same</em> MetaClass instance and does not
- *       rebuild tables.</li>
- * </ol>
- * <p>
- * <b>6.0 decision on MetaClassImpl “hierarchy immunity”:</b>
- * {@code MetaClassImpl} <em>continues</em> to observe ancestor MetaClass state
- * on the missing-method path. Removing that walk would break well-used patterns
- * such as {@code Object.metaClass.foo = …} / parent EMC methods visible on
- * subclasses without giving every subtype its own EMC. Hierarchy SwitchPoint
- * fan-out is therefore retained, but only for the missing-method cases above —
- * pure unmodified {@code MetaClassImpl} ↔ {@code MetaClassImpl} (or null)
- * replace stays <b>exact-class</b>. Most “metaclass changed on the receiver”
- * cases are exact-class — the SwitchPoint retires because <em>that</em>
- * MetaClass changed, not because of hierarchy versioning.
+ * <b>Domain:</b> one SwitchPoint per {@link MetaClass} instance (weak identity
+ * map here). When no class-level MetaClass is installed yet, sites use a
+ * ClassInfo <em>pending</em> domain (retired on first install). Monomorphic
+ * indy sites install a single {@code guardWithTest} on the class-level domain
+ * observed at link time.
  *
- * <h2>When do we invalidate, and how wide?</h2>
- *
- * <table border="1" summary="Invalidation policy">
- *   <tr><th>Event</th><th>Domain retired</th><th>Why</th></tr>
- *   <tr>
- *     <td>EMC method/property update ({@link ClassInfo#incVersion()})</td>
- *     <td>class + loaded subtypes / implementors / array lattice</td>
- *     <td>In-place EMC update can make a previously missing name resolvable on
- *         subtypes via the missing-method hierarchy walk (and can change
- *         already-linked miss targets). Present methods on the child keep
- *         winning after re-link.</td>
- *   </tr>
- *   <tr>
- *     <td>Registry replace where old or new MC is {@link ExpandoMetaClass}</td>
- *     <td>class + hierarchy</td>
- *     <td>Installing / removing EMC changes cross-class MOP visibility for
- *         already-linked subtype sites (miss path / EMC children).</td>
- *   </tr>
- *   <tr>
- *     <td>Registry replace while global EMC creation handle is active</td>
- *     <td>class + hierarchy</td>
- *     <td>Every class MetaClass is an EMC; inheritance refresh can republish
- *         super methods into subtypes.</td>
- *   </tr>
- *   <tr>
- *     <td>Registry replace of an interface or array type</td>
- *     <td>class + hierarchy (array lattice indexed)</td>
- *     <td>Interface / array MetaClasses participate in the same hierarchy walk
- *         (e.g. {@code Object[].metaClass.foo} is visible on {@code String[]}).</td>
- *   </tr>
- *   <tr>
- *     <td>Class replace where <em>both</em> old and new MetaClass are
- *         hierarchy-local (null, or unmodified {@code MetaClassImpl} /
- *         non-EMC subclass with {@code isModified()==false})</td>
- *     <td><b>exact class only</b></td>
- *     <td>Each class owns its own tables; parent replace does not open
- *         {@code findMethodInClassHierarchy}. Over-fan-out would re-link
- *         subtype sites without a MOP-visibility reason.</td>
- *   </tr>
- *   <tr>
- *     <td>Class replace involving unknown / custom MetaClass kinds</td>
- *     <td>class + hierarchy</td>
- *     <td>Correctness-first: custom MetaClasses may publish cross-class state;
- *         exact-only is an allow-list, not a deny-list of EMC alone.</td>
- *   </tr>
- *   <tr>
- *     <td>Per-instance MetaClass change</td>
- *     <td>exact class only</td>
- *     <td>Only that class's monomorphic sites must re-select; per-instance
- *         dispatch is already uncacheable in the PIC. Subtypes are unaffected.</td>
- *   </tr>
- *   <tr>
- *     <td>Category enter/leave, {@code invalidateCallSites()}, unscoped event</td>
- *     <td>all loaded class domains</td>
- *     <td>Category state is process-wide; no second hot-path guard.</td>
- *   </tr>
- * </table>
- *
- * <p>
- * <b>Over- vs under-invalidation:</b> exact-class is an <em>allow-list</em>
- * (hierarchy-local MetaClasses only). Ambiguous or custom MetaClass kinds fan
- * out. {@link #invalidateClass(Class)} / {@code incVersion} always fan out
- * (EMC in-place updates). Linked sites for a method that did not change may
- * still re-link when their receiver class domain is retired — that is the
- * monomorphic SwitchPoint trade-off (same as pre-6.0, but scoped). Hierarchy
- * fan-out deliberately over-invalidates subtype sites whose present methods
- * are unchanged, so that sites which previously linked a <em>miss</em> cannot
- * stay stale; the registry cannot cheaply know which names were linked.
- * <p>
- * <b>Scalability:</b> hierarchy fan-out is {@code O(|loaded subtypes of T|)}
- * via {@link ClassHierarchyIndex}. Category bulk remains a full ClassInfo walk
- * with cheap no-op detaches for domains that never allocated a SwitchPoint.
+ * <h2>Width policy (two axes only)</h2>
+ * <ul>
+ *   <li><b>Exact class</b> — {@link #invalidateClass(Class)}: stock
+ *       {@code MetaClassImpl} / EMC changes (including {@link ClassInfo#incVersion()},
+ *       registry replace, per-instance MetaClass).</li>
+ *   <li><b>All loaded</b> — process-wide retire used for category enter/leave
+ *       ({@link #invalidateCategory()}), unattributed registry events
+ *       ({@link #invalidateUnscoped()}), and non-stock custom MetaClass kinds
+ *       ({@link #invalidateBulk()}).</li>
+ * </ul>
+ * There is <em>no</em> parent→child SwitchPoint fan-out. Indy selection consults
+ * only the receiver’s own MetaClass tables; ancestor-dependent resolution on
+ * the missing-method / missing-property path re-enters
+ * {@code MetaClass.invokeMethod} / property miss and walks the hierarchy live
+ * from the registry each call. Construction-time snapshots of ancestor expando
+ * methods are pre-existing MOP behaviour and are not refreshed by SwitchPoint
+ * retirement. See also {@link MetaClassImpl#findMethodInClassHierarchy}.
  * <p>
- * Install guards with
- * {@link #guardWithMopSwitchPoints(MethodHandle, MethodHandle, Object)}
- * (public / test entry). Production Selector wiring uses
- * {@code IndyInterface.applyMopSwitchPoints}.
- * Optional stats logging: {@code -Dgroovy.indy.invalidation.stats=true}.
+ * Optional stats: {@code -Dgroovy.indy.invalidation.stats=true}.
+ * Production guards: {@code IndyInterface.applyMopSwitchPoints}; tests may use
+ * {@link #guardWithMopSwitchPoints}.
  *
  * @since 6.0.0
  */
@@ -185,10 +76,15 @@ public final class IndyInvalidation {
     private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName());
     private static final boolean STATS_LOG = SystemUtil.getBooleanSafe("groovy.indy.invalidation.stats");
 
+    /** Exact-class invalidation events ({@link #invalidateClass(Class)}). */
     private static final AtomicLong CLASS_INVALIDATIONS = new AtomicLong();
+    /** Category enter/leave bulk events ({@link #invalidateCategory()}). */
     private static final AtomicLong CATEGORY_INVALIDATIONS = new AtomicLong();
-    private static final AtomicLong EXACT_ONLY_INVALIDATIONS = new AtomicLong();
-    private static final AtomicLong HIERARCHY_INVALIDATIONS = new AtomicLong();
+    /**
+     * Non-category process-wide bulk events
+     * ({@link #invalidateBulk()} / {@link #invalidateUnscoped()}).
+     */
+    private static final AtomicLong BULK_INVALIDATIONS = new AtomicLong();
 
     private static final SwitchPoint[] EMPTY_SWITCH_POINTS = new SwitchPoint[0];
 
@@ -212,32 +108,18 @@ public final class IndyInvalidation {
         return new SwitchPointInvalidator();
     }
 
-    /**
-     * Invalidates SwitchPoints for category enter/leave and
-     * {@code VMPlugin.invalidateCallSites()}.
-     * <p>
-     * Implemented as a bulk class-domain invalidation so every linked site
-     * re-links under the new category state. There is no separate category
-     * SwitchPoint on the hot path.
-     */
-    public static void invalidateCategory() {
-        invalidateAllLoadedClassSwitchPoints();
-        CATEGORY_INVALIDATIONS.incrementAndGet();
-        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
-            LOG.fine("category invalidation via bulk class SwitchPoints; total="
-                    + CATEGORY_INVALIDATIONS.get());
-        }
-    }
+    // -------------------------------------------------------------------------
+    // Width: exact class
+    // -------------------------------------------------------------------------
 
     /**
-     * Invalidates the per-class SwitchPoint for {@code type} and every loaded
-     * subtype/implementor (hierarchy fan-out). No-op when {@code type} is
-     * {@code null}. Does not bump {@link ClassInfo#getVersion()} on subtypes.
+     * Retires only {@code type}'s MetaClass SwitchPoint domain (exact class;
+     * no subtype fan-out). No-op when {@code type} is {@code null}. Does not
+     * bump {@link ClassInfo#getVersion()}.
      * <p>
-     * Used by {@link ClassInfo#incVersion()} (EMC method updates) and any
-     * caller that needs the conservative full-hierarchy policy. Registry
-     * listeners prefer {@link #invalidateForMetaClassChange(MetaClassRegistryChangeEvent)}
-     * so pure {@code MetaClassImpl} replacements stay exact-class.
+     * Used by {@link ClassInfo#incVersion()}, stock registry MetaClass changes,
+     * and per-instance MetaClass changes. Non-stock MetaClass kinds use
+     * {@link #invalidateBulk()} instead.
      *
      * @param type the class whose MetaClass changed
      */
@@ -245,40 +127,81 @@ public final class IndyInvalidation {
         if (type == null) {
             return;
         }
-        invalidateClassHierarchy(type);
+        List<SwitchPoint> batch = new ArrayList<>(1);
+        collectLiveForClass(type, batch);
+        invalidateBatch(batch);
         CLASS_INVALIDATIONS.incrementAndGet();
-        HIERARCHY_INVALIDATIONS.incrementAndGet();
         if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
-            LOG.fine("class SwitchPoint hierarchy invalidated for " + type.getName()
-                    + "; total=" + CLASS_INVALIDATIONS.get());
+            LOG.fine("exact-class SwitchPoint invalidated for " + type.getName()
+                    + "; totalClass=" + CLASS_INVALIDATIONS.get());
         }
     }
 
+    // -------------------------------------------------------------------------
+    // Width: all loaded (one primitive, reason-labelled public entries)
+    // -------------------------------------------------------------------------
+
     /**
-     * Retires only {@code type}'s MetaClass SwitchPoint domain (no subtype fan-out).
-     * Used when MetaClass change cannot affect cross-class MOP visibility
-     * (pure {@code MetaClassImpl} class replace, per-instance MetaClass).
-     *
-     * @param type the class whose domain is retired; no-op when {@code null}
+     * Category enter/leave and {@code VMPlugin.invalidateCallSites()}.
+     * Retires every loaded class-level domain so sites re-link under the new
+     * category state. No separate category SwitchPoint on the hot path.
      */
-    public static void invalidateClassExact(final Class<?> type) {
-        if (type == null) {
-            return;
+    public static void invalidateCategory() {
+        retireAllLoadedDomains();
+        CATEGORY_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("category bulk invalidation; total=" + CATEGORY_INVALIDATIONS.get());
         }
-        List<SwitchPoint> batch = new ArrayList<>(1);
-        collectLiveForClass(type, batch);
-        invalidateBatch(batch);
-        CLASS_INVALIDATIONS.incrementAndGet();
-        EXACT_ONLY_INVALIDATIONS.incrementAndGet();
+    }
+
+    /**
+     * Process-wide bulk retirement for non-stock (custom) MetaClass registry
+     * events — correctness-first when selection may consult state outside the
+     * stock miss walk. Rare.
+     */
+    public static void invalidateBulk() {
+        retireAllLoadedDomains();
+        BULK_INVALIDATIONS.incrementAndGet();
         if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
-            LOG.fine("exact-class SwitchPoint invalidated for " + type.getName()
-                    + "; totalExact=" + EXACT_ONLY_INVALIDATIONS.get());
+            LOG.fine("custom-MetaClass bulk invalidation; totalBulk=" + BULK_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Process-wide bulk retirement when a MetaClass registry event carries no
+     * {@code Class} attribution.
+     */
+    public static void invalidateUnscoped() {
+        retireAllLoadedDomains();
+        BULK_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("unscoped bulk invalidation; totalBulk=" + BULK_INVALIDATIONS.get());
         }
     }
 
     /**
-     * Registry-driven invalidation with MetaClass-aware fan-out policy.
-     * See class javadoc table for the decision matrix.
+     * Detaches and invalidates every live class-level MetaClass / pending domain
+     * for loaded types. Shared implementation for all process-wide entries.
+     */
+    private static void retireAllLoadedDomains() {
+        List<SwitchPoint> batch = new ArrayList<>();
+        try {
+            for (ClassInfo info : ClassInfo.getAllClassInfo()) {
+                info.collectLiveIndySwitchPoints(batch);
+            }
+        } finally {
+            invalidateBatch(batch);
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Registry policy
+    // -------------------------------------------------------------------------
+
+    /**
+     * Registry-driven invalidation: exact class for stock MetaClass kinds and
+     * per-instance changes; process-wide bulk for custom MetaClass kinds or
+     * unattributed events. See class javadoc.
      *
      * @param event the registry change event (must not be {@code null})
      */
@@ -289,108 +212,43 @@ public final class IndyInvalidation {
             return;
         }
         if (event.isPerInstanceMetaClassChange()) {
-            // Per-instance MC: only this class's monomorphic sites re-select.
-            invalidateClassExact(type);
+            invalidateClass(type);
             return;
         }
-        if (needsHierarchyFanOut(type, event.getOldMetaClass(), event.getNewMetaClass())) {
-            invalidateClass(type);
+        if (needsBulkInvalidation(event.getOldMetaClass(), event.getNewMetaClass())) {
+            invalidateBulk();
         } else {
-            invalidateClassExact(type);
+            invalidateClass(type);
         }
     }
 
     /**
-     * Whether a class-level MetaClass change for {@code type} can affect
-     * already-linked sites on subtypes / array-covariance descendants.
+     * Whether a class-level MetaClass change involving {@code oldMc}/{@code newMc}
+     * requires process-wide bulk invalidation.
      * <p>
-     * Hierarchy fan-out is the default. Exact-class is an <em>allow-list</em>:
-     * both old and new MetaClass must be {@linkplain #isHierarchyLocalMetaClass
-     * hierarchy-local}, and {@code type} must not be an interface/array, and
-     * global EMC must be off. Unknown / custom MetaClass kinds therefore fan
-     * out (correctness-first).
-     * <p>
-     * Aligns with {@code MetaClassImpl.findMethodInClassHierarchy}: the live
-     * hierarchy walk opens only when a modified {@link MutableMetaClass} is
-     * present (EMC install/remove/update). Pure unmodified
-     * {@code MetaClassImpl} pairs never open that walk, so they stay
-     * exact-class. Fan-out is still required for the missing-method case even
-     * when a subtype already has <em>other</em> methods of the same name with
-     * different signatures — a previously unlinked {@code child.m2("")} has no
-     * site to invalidate, but a previously linked miss must re-select.
+     * Bulk is reserved for <em>non-stock</em> MetaClass kinds: not
+     * {@link MetaClassImpl} after adapter unwrap. Stock pairs stay exact-class.
      *
-     * @param type  the class being updated (not {@code null})
      * @param oldMc previous MetaClass (may be {@code null})
      * @param newMc new MetaClass (may be {@code null} on remove)
-     * @return {@code true} if subtype fan-out is required
+     * @return {@code true} if bulk invalidation is required
      */
-    public static boolean needsHierarchyFanOut(
-            final Class<?> type, final MetaClass oldMc, final MetaClass newMc) {
-        if (type.isInterface() || type.isArray()) {
-            return true;
-        }
-        if (isGlobalEmcEnabled()) {
-            return true;
-        }
-        // Exact-only only when BOTH sides are hierarchy-local.
-        return !isHierarchyLocalMetaClass(oldMc) || !isHierarchyLocalMetaClass(newMc);
+    public static boolean needsBulkInvalidation(final MetaClass oldMc, final MetaClass newMc) {
+        return !isStockMetaClass(oldMc) || !isStockMetaClass(newMc);
     }
 
     /**
-     * Whether {@code mc} cannot publish cross-class MOP state into subtype
-     * selection via the live missing-method hierarchy walk (or EMC inheritance).
-     * Hierarchy-local means:
-     * <ul>
-     *   <li>{@code null} (absent / removed), or</li>
-     *   <li>unwrapped {@code MetaClassImpl} that is not an
-     *       {@link ExpandoMetaClass} and reports {@code isModified()==false}.</li>
-     * </ul>
-     * EMC, modified mutable MetaClasses, and unknown MetaClass kinds return
-     * {@code false} so fan-out remains correctness-first.
-     * <p>
-     * Note: a hierarchy-local {@code MetaClassImpl} may still <em>contain</em>
-     * methods snapshotted from an ancestor at construction time; that snapshot
-     * is fixed for the MetaClass instance’s lifetime and is not refreshed by
-     * SwitchPoint retirement. See class javadoc.
+     * Whether {@code mc} is a stock MOP MetaClass for which exact-class
+     * invalidation is sufficient: {@code null}, or unwrapped
+     * {@link MetaClassImpl} (includes EMC and subclasses). Any other kind is
+     * non-stock and triggers bulk invalidation on registry replace.
      *
      * @param mc MetaClass to classify (may be {@code null})
-     * @return {@code true} if a replace involving only this kind needs no fan-out
+     * @return {@code true} if exact-class invalidation is sufficient
      */
-    public static boolean isHierarchyLocalMetaClass(final MetaClass mc) {
+    public static boolean isStockMetaClass(final MetaClass mc) {
         MetaClass current = unwrapMetaClass(mc);
-        if (current == null) {
-            return true;
-        }
-        // EMC always participates in cross-class visibility (even before the
-        // first method marks isModified), so it is never hierarchy-local.
-        if (current instanceof ExpandoMetaClass) {
-            return false;
-        }
-        if (current instanceof MutableMetaClass mutable && mutable.isModified()) {
-            return false;
-        }
-        // Unmodified MetaClassImpl (and non-EMC subclasses with isModified==false).
-        return current instanceof MetaClassImpl;
-    }
-
-    /**
-     * {@code true} when the registry creates {@link ExpandoMetaClass} instances
-     * by default ({@link ExpandoMetaClass#enableGlobally()}).
-     */
-    static boolean isGlobalEmcEnabled() {
-        MetaClassRegistry registry = GroovySystem.getMetaClassRegistry();
-        // Registry may be null while GroovySystem is still constructing it.
-        return registry != null
-                && registry.getMetaClassCreationHandler() instanceof ExpandoMetaClassCreationHandle;
-    }
-
-    /**
-     * Whether {@code mc} is (or wraps) an {@link ExpandoMetaClass}.
-     * Wrappers are unwrapped via {@link AdaptingMetaClass#getAdaptee()} or
-     * {@link DelegatingMetaClass#getAdaptee()} ({@code HandleMetaClass}).
-     */
-    static boolean isExpandoMetaClass(final MetaClass mc) {
-        return unwrapMetaClass(mc) instanceof ExpandoMetaClass;
+        return current == null || current instanceof MetaClassImpl;
     }
 
     /**
@@ -423,6 +281,10 @@ public final class IndyInvalidation {
         return null;
     }
 
+    // -------------------------------------------------------------------------
+    // Domain map
+    // -------------------------------------------------------------------------
+
     /**
      * Domain key for a MetaClass (unwrapped adaptee when present).
      */
@@ -467,56 +329,6 @@ public final class IndyInvalidation {
         ClassInfo.getClassInfo(type).collectLiveIndySwitchPoints(out);
     }
 
-    /**
-     * Invalidates SwitchPoints for {@code type} and all loaded classes assignable
-     * from {@code type}. Detach the root domain, then every descendant in
-     * {@link ClassHierarchyIndex}. Batched through
-     * {@link SwitchPoint#invalidateAll(SwitchPoint[])}.
-     *
-     * @param type the root of the invalidation fan-out
-     */
-    public static void invalidateClassHierarchy(final Class<?> type) {
-        List<SwitchPoint> batch = new ArrayList<>();
-        try {
-            collectLiveForClass(type, batch);
-            List<ClassInfo> descendants = new ArrayList<>();
-            ClassHierarchyIndex.collectDescendants(type, descendants);
-            for (ClassInfo info : descendants) {
-                info.collectLiveIndySwitchPoints(batch);
-            }
-        } finally {
-            invalidateBatch(batch);
-        }
-    }
-
-    /**
-     * Detaches and invalidates every live class-level MetaClass / pending domain
-     * for loaded types. Used for category enter/leave and unattributed MetaClass
-     * changes.
-     */
-    public static void invalidateAllLoadedClassSwitchPoints() {
-        List<SwitchPoint> batch = new ArrayList<>();
-        try {
-            for (ClassInfo info : ClassInfo.getAllClassInfo()) {
-                info.collectLiveIndySwitchPoints(batch);
-            }
-        } finally {
-            invalidateBatch(batch);
-        }
-    }
-
-    /**
-     * Invalidates every live class-level MetaClass SwitchPoint. Used when a
-     * MetaClass registry event carries no {@code Class} attribution.
-     */
-    public static void invalidateUnscoped() {
-        invalidateAllLoadedClassSwitchPoints();
-        CLASS_INVALIDATIONS.incrementAndGet();
-        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
-            LOG.fine("unscoped SwitchPoint invalidation; total=" + CLASS_INVALIDATIONS.get());
-        }
-    }
-
     private static void invalidateBatch(final List<SwitchPoint> batch) {
         if (batch.isEmpty()) {
             return;
@@ -524,6 +336,10 @@ public final class IndyInvalidation {
         SwitchPoint.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS));
     }
 
+    // -------------------------------------------------------------------------
+    // Link-time domain resolution and guards
+    // -------------------------------------------------------------------------
+
     /**
      * Resolves the class used for the MetaClass SwitchPoint domain of a receiver.
      * {@code null} maps to {@link NullObject}; a {@link Class} receiver uses itself.
@@ -543,7 +359,6 @@ public final class IndyInvalidation {
 
     /**
      * Returns the SwitchPoint domain for a MetaClass instance (unwraps adapters).
-     * All MetaClass kinds share the same weak identity map of domains.
      *
      * @param mc MetaClass (must not be {@code null})
      * @return the MetaClass-domain SwitchPoint
@@ -555,6 +370,7 @@ public final class IndyInvalidation {
 
     /**
      * Retires the SwitchPoint domain for {@code mc} (if one was allocated).
+     * Does not bump counters (local domain retire; width policy is separate).
      *
      * @param mc MetaClass (may be {@code null})
      */
@@ -580,8 +396,7 @@ public final class IndyInvalidation {
     /**
      * Returns the SwitchPoint for the given class at link time.
      * Prefers the installed class-level MetaClass domain; if none is installed
-     * yet, uses the ClassInfo pending domain (defineClass-safe — does not create
-     * a MetaClass). First MetaClass install retires the pending domain.
+     * yet, uses the ClassInfo pending domain (defineClass-safe).
      *
      * @param type the class (must not be {@code null})
      * @return the SwitchPoint for monomorphic MOP guards
@@ -597,12 +412,7 @@ public final class IndyInvalidation {
 
     /**
      * Installs a MetaClass SwitchPoint guard on {@code handle}.
-     * MetaClass or category changes that retire that domain cause the site to
-     * take {@code fallback}.
-     * <p>
-     * Public entry for tests and any code outside {@code vmplugin.v8}. Production
-     * call-site linking goes through {@code IndyInterface.applyMopSwitchPoints}
-     * (same {@code guardWithTest} after {@link #classSwitchPointFor(Object)}).
+     * Public entry for tests; production uses {@code IndyInterface.applyMopSwitchPoints}.
      *
      * @param handle   the fast-path handle
      * @param fallback the re-link / fallback handle
@@ -616,7 +426,6 @@ public final class IndyInvalidation {
 
     /**
      * Installs a MetaClass SwitchPoint guard on {@code handle}.
-     * See {@link #guardWithMopSwitchPoints(MethodHandle, MethodHandle, Object)}.
      *
      * @param handle        the fast-path handle
      * @param fallback      the re-link / fallback handle
@@ -628,10 +437,12 @@ public final class IndyInvalidation {
         return classSwitchPointFor(receiverClass).guardWithTest(handle, fallback);
     }
 
+    // -------------------------------------------------------------------------
+    // Stats (optional; tests)
+    // -------------------------------------------------------------------------
+
     /**
-     * Returns the total number of scoped class invalidations
-     * ({@link #invalidateClass(Class)} / {@link #invalidateClassExact(Class)} /
-     * {@link #invalidateForMetaClassChange} / {@link #invalidateUnscoped()} events).
+     * Exact-class invalidation event count ({@link #invalidateClass(Class)}).
      *
      * @return class invalidation count
      */
@@ -640,25 +451,17 @@ public final class IndyInvalidation {
     }
 
     /**
-     * Returns how many class invalidations used hierarchy fan-out.
-     *
-     * @return hierarchy fan-out event count
-     */
-    public static long hierarchyInvalidationCount() {
-        return HIERARCHY_INVALIDATIONS.get();
-    }
-
-    /**
-     * Returns how many class invalidations were exact-class only.
+     * Non-category process-wide bulk event count
+     * ({@link #invalidateBulk()} / {@link #invalidateUnscoped()}).
      *
-     * @return exact-only event count
+     * @return bulk invalidation count
      */
-    public static long exactOnlyInvalidationCount() {
-        return EXACT_ONLY_INVALIDATIONS.get();
+    public static long bulkInvalidationCount() {
+        return BULK_INVALIDATIONS.get();
     }
 
     /**
-     * Returns the total number of category-style bulk invalidations.
+     * Category-style bulk invalidation count ({@link #invalidateCategory()}).
      *
      * @return category invalidation count
      */
@@ -672,7 +475,6 @@ public final class IndyInvalidation {
     public static void resetCountersForTesting() {
         CLASS_INVALIDATIONS.set(0);
         CATEGORY_INVALIDATIONS.set(0);
-        EXACT_ONLY_INVALIDATIONS.set(0);
-        HIERARCHY_INVALIDATIONS.set(0);
+        BULK_INVALIDATIONS.set(0);
     }
 }
diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
index 0b6c8dc37d..79cb0ddbc0 100644
--- a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
+++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
@@ -30,13 +30,13 @@ import java.util.concurrent.atomic.AtomicReference;
  * point so linked sites fall back and re-link; the next {@link #getSwitchPoint()}
  * allocates a fresh one.
  * <p>
- * Hierarchy fan-out and other bulk paths should prefer {@link #detachLive()} and a
- * single {@link SwitchPoint#invalidateAll(SwitchPoint[])} over many individual
- * {@link #invalidate()} calls (GROOVY-12191).
+ * Bulk paths (category enter/leave, custom MetaClass events) should prefer
+ * {@link #detachLive()} and a single {@link SwitchPoint#invalidateAll(SwitchPoint[])}
+ * over many individual {@link #invalidate()} calls (GROOVY-12191).
  * <p>
- * There is no permanent failover: after scoping invalidation to a single class
- * (plus subtypes), deopt storms from a process-wide SwitchPoint no longer apply,
- * so a simpler replace-on-invalidate model is sufficient.
+ * There is no permanent failover: after scoping stock invalidation to a single
+ * class, deopt storms from a process-wide SwitchPoint no longer apply, so a
+ * simpler replace-on-invalidate model is sufficient.
  *
  * @since 6.0.0
  */
diff --git a/src/main/java/org/apache/groovy/runtime/indy/package-info.java b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
index 29eeffdce4..980309572f 100644
--- a/src/main/java/org/apache/groovy/runtime/indy/package-info.java
+++ b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
@@ -20,28 +20,8 @@
 /**
  * Scoped invokedynamic SwitchPoint utilities for the Groovy MOP (GROOVY-12191).
  * <p>
- * <b>Domain model:</b> one SwitchPoint per {@link groovy.lang.MetaClass}
- * instance (weak identity map in {@link IndyInvalidation}). No SwitchPoint field
- * on {@link groovy.lang.MetaClassImpl}; custom MetaClasses use the same map.
- * When no class-level MetaClass is installed yet, link uses a ClassInfo
- * <em>pending</em> domain (retired on first MetaClass install) — defineClass-
- * safe without an always-invalid SwitchPoint (which would monomorphic-loop).
- * MetaClass-aware policy decides exact-class vs hierarchy fan-out:
- * <ul>
- *   <li>EMC, modified custom MetaClass, global EMC, interface and array types
- *       → class + loaded subtypes (incl. array lattice)</li>
- *   <li>hierarchy-local {@code MetaClassImpl} pair, per-instance MetaClass
- *       → exact class only</li>
- *   <li>category enter/leave and unscoped events → bulk retire class-level
- *       MetaClass domains of loaded types</li>
- * </ul>
- * Hierarchy fan-out exists only for the missing-method walk
- * ({@code MetaClassImpl.findMethodInClassHierarchy}). MetaClassImpl continues
- * to observe ancestor MetaClass state on that path (6.0 decision). See
- * {@link IndyInvalidation}.
- * <p>
- * Hierarchy fan-out is indexed by {@link ClassHierarchyIndex}. Install guards
- * via {@link IndyInvalidation#guardWithMopSwitchPoints} or
+ * Canonical policy and domain model: {@link IndyInvalidation}.
+ * Install guards via {@link IndyInvalidation#guardWithMopSwitchPoints} or
  * {@code IndyInterface.applyMopSwitchPoints}.
  *
  * @since 6.0.0
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
index 5106930ea5..c002226ba9 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
+++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
@@ -26,7 +26,6 @@ import groovy.lang.MetaClass;
 import groovy.lang.MetaClassRegistry;
 import groovy.lang.MetaMethod;
 import groovy.transform.Internal;
-import org.apache.groovy.runtime.indy.ClassHierarchyIndex;
 import org.apache.groovy.runtime.indy.IndyInvalidation;
 import org.apache.groovy.runtime.indy.SwitchPointInvalidator;
 import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
@@ -125,9 +124,6 @@ public class ClassInfo implements Finalizable {
         this.classRef = new WeakReference<Class<?>>(klazz);
         cachedClassRef = new LazyCachedClassRef(softBundle, this);
         artifactClassLoader = new LazyClassLoaderRef(softBundle, this);
-        // Index under MOP-relevant ancestors so hierarchy SwitchPoint fan-out is
-        // O(descendants) rather than O(all loaded ClassInfos) (GROOVY-12191).
-        ClassHierarchyIndex.register(this);
     }
 
     /**
@@ -142,23 +138,14 @@ public class ClassInfo implements Finalizable {
 
     /**
      * Increments the version number and invalidates this class's MetaClass
-     * SwitchPoint (and subtype MetaClass SwitchPoints via hierarchy fan-out).
-     * Called when metaclass modifications occur (e.g., adding methods to an {@code ExpandoMetaClass}).
-     * <p>
-     * Hierarchy fan-out is intentional here: {@code incVersion} is the path used
-     * when an existing MetaClass is <em>updated in place</em> (EMC method/property
-     * add). That update can make a previously <em>missing</em> name resolvable on
-     * subtype receivers via {@code MetaClassImpl.findMethodInClassHierarchy}
-     * (opens only when a modified {@code MutableMetaClass} exists in the
-     * hierarchy), so already-linked subtype miss sites must re-select. Present
-     * methods on the child keep winning after re-link. Registry-driven full
-     * MetaClass <em>replacements</em> use MetaClass-aware policy instead (pure
-     * {@code MetaClassImpl} replace stays exact-class; see
-     * {@link IndyInvalidation#invalidateForMetaClassChange}).
+     * SwitchPoint domain (exact class only). Called when metaclass modifications
+     * occur (e.g., adding methods to an {@code ExpandoMetaClass}).
      * <p>
      * <strong>Behavioral change in 6.0 (GROOVY-12191):</strong> this method no longer
      * triggers a process-wide call-site flush. It scopes invalidation to this class
-     * and loaded subtypes/implementors only. Callers that previously relied on
+     * only. Subtype sites are not retired: stock missing-method / missing-property
+     * hierarchy walks re-resolve live on the dynamic route (see
+     * {@link IndyInvalidation}). Callers that previously relied on
      * {@code incVersion()} as a global “flush everything” hammer should invoke
      * {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()} (or
      * {@link IndyInvalidation#invalidateCategory()}) explicitly when a bulk flush
@@ -167,7 +154,7 @@ public class ClassInfo implements Finalizable {
      * Category enter/leave still bulk-invalidates MetaClass SwitchPoints via
      * {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()}.
      * SwitchPoint policy is owned by {@link IndyInvalidation}; this method only
-     * bumps generation then delegates hierarchy invalidation.
+     * bumps generation then delegates exact-class invalidation.
      */
     public void incVersion() {
         version.incrementAndGet();
@@ -184,14 +171,16 @@ public class ClassInfo implements Finalizable {
      * <ul>
      *   <li>First MetaClass install: retire pending domain only (sites linked
      *       pre-MC re-select onto the MetaClass domain).</li>
-     *   <li>Replace/clear: retire the current MetaClass domain (and any leftover
-     *       pending). Hierarchy fan-out is applied by the registry listener.</li>
+     *   <li>Replace/clear: retire this ClassInfo’s domains locally. When the
+     *       change also fires a MetaClass registry event, the listener applies
+     *       width policy ({@link IndyInvalidation#invalidateForMetaClassChange})
+     *       — often a second detach of an already-empty domain (idempotent).</li>
      * </ul>
      */
     private void bumpGenerationLocal(final boolean retireSwitchPoint) {
         version.incrementAndGet();
         if (retireSwitchPoint) {
-            // Local only: registry listener will fan out to subtypes when present.
+            // Local domain ownership: width policy is IndyInvalidation's job.
             invalidateIndySwitchPoint();
         } else {
             // First MetaClass install — retire pending pre-MC domain only.
@@ -418,15 +407,16 @@ public class ClassInfo implements Finalizable {
      * <p>
      * On replacement or clear, also invalidates this class's indy SwitchPoint
      * (GROOVY-12191). First install ({@code null →} MC) only bumps version;
-     * hierarchy fan-out for subtypes is applied by MetaClass registry listeners
-     * or {@link #incVersion()}.
+     * exact-class invalidation for the registry event is applied by MetaClass
+     * registry listeners or {@link #incVersion()}.
      *
      * @param answer the metaclass to set, or {@code null} to clear
      */
     public void setStrongMetaClass(MetaClass answer) {
         // Version always; SwitchPoint when replacing/clearing an established MC.
-        // Hierarchy fan-out for subtypes is applied by registry listeners / incVersion
-        // (GROOVY-12191). First install (null → MC) only bumps version.
+        // Exact-class (stock) or bulk (custom) policy is applied by registry
+        // listeners / incVersion (GROOVY-12191). First install (null → MC) only
+        // bumps version.
         boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
         bumpGenerationLocal(replaceOrClear);
 
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 85ff2a2798..05cd876150 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -221,17 +221,14 @@ public class IndyInterface {
     }
 
     static {
-        // MetaClass registry changes invalidate the affected class + subtypes (GROOVY-12191).
-        // Hierarchy fan-out is owned here (not duplicated in setStrongMetaClass beyond a
-        // local retire of the exact class for paths that never fire the registry).
+        // MetaClass registry changes invalidate the affected class domain (GROOVY-12191).
+        // Stock MetaClassImpl/EMC → exact class; custom MetaClass kinds → bulk.
         //
         // Pre-6.0 this listener rotated a process-wide SwitchPoint field on this class.
         // That field is removed (vmplugin is internal-by-intent): call sites now guard on
         // MetaClass-owned domains via applyMopSwitchPoints.
         GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> {
-            // MetaClass-aware fan-out: EMC / interface / array / global-EMC → hierarchy;
-            // pure MetaClassImpl class replace and per-instance MC → exact class only.
-            // See IndyInvalidation class javadoc for the decision matrix (GROOVY-12191).
+            // Exact-class (stock) vs process-wide bulk (custom / unscoped) — IndyInvalidation.
             IndyInvalidation.invalidateForMetaClassChange(cmcu);
             if (LOG_ENABLED) {
                 Class<?> type = cmcu.getClassToUpdate();
@@ -250,8 +247,8 @@ public class IndyInterface {
      * Bulk-invalidates every loaded class SwitchPoint so sites re-link under the
      * new category state. Per-class MetaClass changes use the MetaClass-aware
      * registry path ({@link IndyInvalidation#invalidateForMetaClassChange}) or
-     * {@link org.codehaus.groovy.reflection.ClassInfo#incVersion()} (hierarchy
-     * fan-out for in-place EMC updates).
+     * {@link org.codehaus.groovy.reflection.ClassInfo#incVersion()} (exact-class
+     * for in-place EMC updates).
      * <p>
      * Pre-6.0 this method also rotated a process-wide {@code switchPoint} field.
      * That field is gone; bulk policy lives entirely in {@link IndyInvalidation}.
diff --git a/src/test/groovy/bugs/Groovy12191.groovy b/src/test/groovy/bugs/Groovy12191.groovy
index 865a133a13..2f66eb0c90 100644
--- a/src/test/groovy/bugs/Groovy12191.groovy
+++ b/src/test/groovy/bugs/Groovy12191.groovy
@@ -34,7 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue
  * GROOVY-12191: scoped indy SwitchPoint invalidation.
  * Metaclass changes for class A must not invalidate class B's SwitchPoint;
  * category enter/leave bulk-invalidates class SwitchPoints so category methods
- * become visible to previously linked sites.
+ * become visible to previously linked sites. Stock MetaClassImpl/EMC changes
+ * are exact-class only — no parent→child SwitchPoint fan-out (PR #2736).
  */
 final class Groovy12191 {
 
@@ -77,7 +78,6 @@ final class Groovy12191 {
             assert 'decorated' == new TypeD().label()
         }
 
-        // Category enter/leave bulk-invalidates class domains (single-guard model).
         assertTrue(classSp.hasBeenInvalidated())
     }
 
@@ -116,17 +116,16 @@ final class Groovy12191 {
     }
 
     @Test
-    void parentMetaClassChange_invalidatesSubclassSwitchPoint() {
-        // Hierarchy fan-out is required for cross-class MOP visibility:
-        // Parent.metaClass (EMC) mutations must retire Child-linked sites so
-        // new Child().hello() re-selects. This is not MetaClassImpl sharing a
-        // table up the hierarchy — each class keeps its own MetaClass.
+    void parentMetaClassChange_doesNotInvalidateSubclassSwitchPoint() {
+        // Stock policy: parent EMC mutation retires only the parent's domain.
+        // Child sites stay warm; the live miss route observes the new method.
         SwitchPoint childSp = ClassInfo.getClassInfo(HierChild).indySwitchPoint
         assertFalse(childSp.hasBeenInvalidated())
 
         HierParent.metaClass.hello = { -> 'from-parent' }
 
-        assertTrue(childSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'parent EMC must not retire subclass SwitchPoint (stock exact-class)')
         assertScript '''
             class HierParent {}
             class HierChild extends HierParent {}
@@ -137,9 +136,6 @@ final class Groovy12191 {
 
     @Test
     void parentMetaClassImplReplace_doesNotFanOutToSubclassSwitchPoint() {
-        // Pure MetaClassImpl ↔ MetaClassImpl replace has no cross-class EMC
-        // visibility: each class owns its own tables. Hierarchy fan-out would
-        // only re-link subtype sites without a MOP reason (blackdrag review).
         SwitchPoint parentSp = ClassInfo.getClassInfo(HierParentMcImpl).indySwitchPoint
         SwitchPoint childSp = ClassInfo.getClassInfo(HierChildMcImpl).indySwitchPoint
         assertFalse(parentSp.hasBeenInvalidated())
@@ -159,9 +155,7 @@ final class Groovy12191 {
     }
 
     @Test
-    void parentEmcReplace_fansOutToSubclassSwitchPoint() {
-        // Installing EMC on Parent publishes methods into Child via hierarchy
-        // walk — subtype sites must re-link.
+    void parentEmcReplace_doesNotFanOutToSubclassSwitchPoint() {
         SwitchPoint childSp = ClassInfo.getClassInfo(HierChildEmc).indySwitchPoint
         assertFalse(childSp.hasBeenInvalidated())
 
@@ -169,8 +163,8 @@ final class Groovy12191 {
         emc.initialize()
         GroovySystem.metaClassRegistry.setMetaClass(HierParentEmc, emc)
         try {
-            assertTrue(childSp.hasBeenInvalidated(),
-                    'EMC install on parent must retire subclass SwitchPoint')
+            assertFalse(childSp.hasBeenInvalidated(),
+                    'EMC install on parent must not retire subclass SwitchPoint')
         } finally {
             GroovySystem.metaClassRegistry.removeMetaClass(HierParentEmc)
         }
@@ -178,7 +172,6 @@ final class Groovy12191 {
 
     @Test
     void unrelatedTypeMetaClassChange_doesNotInvalidateSiblingHierarchy() {
-        // Hierarchy fan-out is only to subtypes of the changed type — not global.
         SwitchPoint hotSp = ClassInfo.getClassInfo(HierChildScope).indySwitchPoint
         assertFalse(hotSp.hasBeenInvalidated())
 
@@ -210,7 +203,6 @@ final class Groovy12191 {
         SwitchPoint hotSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
         assertFalse(hotSp.hasBeenInvalidated())
 
-        // Churn an unrelated type — scoped invalidation must leave TypeD alone.
         10.times { i ->
             TypeC.metaClass."dyn${i}" = { -> i }
         }
@@ -218,9 +210,9 @@ final class Groovy12191 {
     }
 
     @Test
-    void objectArrayMetaClassChange_invalidatesStringArraySwitchPoint() {
-        // Array types are final, yet Object[] is the MOP supertype of reference
-        // arrays — scoped invalidation must fan out (GROOVY-12191 review).
+    void objectArrayMetaClassChange_doesNotInvalidateStringArraySwitchPoint() {
+        // Stock exact-class: Object[] EMC does not retire String[] domain.
+        // Dispatch still sees the method via the live miss hierarchy walk.
         SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
         assertFalse(stringArraySp.hasBeenInvalidated())
 
@@ -228,15 +220,15 @@ final class Groovy12191 {
         emc.initialize()
         GroovySystem.metaClassRegistry.setMetaClass(Object[], emc)
         try {
-            assertTrue(stringArraySp.hasBeenInvalidated(),
-                    'Object[] MetaClass change must retire String[] SwitchPoint')
+            assertFalse(stringArraySp.hasBeenInvalidated(),
+                    'Object[] MetaClass change must not retire String[] SwitchPoint')
         } finally {
             GroovySystem.metaClassRegistry.removeMetaClass(Object[])
         }
     }
 
     @Test
-    void incVersion_scopesToClassHierarchy_notGlobal() {
+    void incVersion_scopesToExactClass_notGlobal() {
         SwitchPoint hotSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
         SwitchPoint targetSp = ClassInfo.getClassInfo(TypeC).indySwitchPoint
         assertFalse(hotSp.hasBeenInvalidated())
@@ -252,12 +244,10 @@ final class Groovy12191 {
     /**
      * Blackdrag scenario matrix (PR #2736): {@code call(c)} uses receiver class C.
      * Mutations on B/A must not retire C's domain; methods added on B/A must not
-     * become visible on a pure C instance. Documents old-vs-new blast radius and
-     * actual MOP visibility.
+     * become visible on a pure C instance. Stock policy is exact-class throughout.
      */
     @Test
     void blackdragScenario_callOnC_notInvalidatedBySubclassMetaClassChurn() {
-        // Touch receivers first so MetaClass-owned domains exist; capture SPs after.
         def c = new BdC()
         assert c.fooC() == 'fooC'
         new BdB()
@@ -269,13 +259,13 @@ final class Groovy12191 {
         assertFalse(spB.hasBeenInvalidated())
         assertFalse(spA.hasBeenInvalidated())
 
-        // B.metaClass = EMC + method — fans out to A (subtype of B), not to C (supertype).
+        // B.metaClass = EMC + method — exact-class B only (not A, not C).
         def emcB = new ExpandoMetaClass(BdB, true, true)
         emcB.initialize()
         GroovySystem.metaClassRegistry.setMetaClass(BdB, emcB)
         try {
             assertTrue(spB.hasBeenInvalidated())
-            assertTrue(spA.hasBeenInvalidated(), 'EMC on B must fan out to A')
+            assertFalse(spA.hasBeenInvalidated(), 'EMC on B must not fan out to A')
             assertFalse(spC.hasBeenInvalidated(), 'EMC on B must not retire C (supertype)')
             assert c.fooC() == 'fooC'
             assert !c.metaClass.respondsTo(c, 'fooB0')
@@ -283,17 +273,17 @@ final class Groovy12191 {
             spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
             spA = ClassInfo.getClassInfo(BdA).indySwitchPoint
             BdB.metaClass.fooB0 = { -> 'fooB0' }
-            assertTrue(spA.hasBeenInvalidated(), 'B EMC update (incVersion) fans out to A')
+            // in-place EMC update retires B's domain again; A/C stay warm
+            assertFalse(spA.hasBeenInvalidated(), 'B EMC update must not fan out to A')
             assertFalse(spC.hasBeenInvalidated())
             assert new BdB().fooB0() == 'fooB0'
-            assert new BdA().fooB0() == 'fooB0'
+            assert new BdA().fooB0() == 'fooB0' // live hierarchy walk
             assert !c.metaClass.respondsTo(c, 'fooB0')
             assert c.fooC() == 'fooC'
         } finally {
             GroovySystem.metaClassRegistry.removeMetaClass(BdB)
         }
 
-        // A.fooA = {} — exact class A (+ subtypes of A if any), not C.
         spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
         BdA.metaClass.fooA = { -> 'fooA' }
         try {
@@ -305,7 +295,6 @@ final class Groovy12191 {
             GroovySystem.metaClassRegistry.removeMetaClass(BdA)
         }
 
-        // Per-instance EMC on a B instance — does not affect C domain or visibility on c.
         spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
         def b = new BdB()
         def emcInst = new ExpandoMetaClass(BdB, false, true)
@@ -323,7 +312,6 @@ final class Groovy12191 {
 
     @Test
     void objectArrayEmcMethod_visibleOnStringArray() {
-        // Empirical MOP fact that justifies array lattice fan-out for EMC.
         assertScript '''
             Object[].metaClass.arrHello = { -> 'arr' }
             try {
@@ -335,6 +323,28 @@ final class Groovy12191 {
         '''
     }
 
+    /**
+     * Behaviour-only: linked miss on String[] then Object[] EMC add must become
+     * visible without String[] SwitchPoint retirement (PR #2736 experiment).
+     */
+    @Test
+    void linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible() {
+        assertScript '''
+            def probe(x) {
+                try { return x.arrHello() } catch (MissingMethodException e) { return 'miss' }
+            }
+            String[] arr = ['a', 'b']
+            200.times { assert probe(arr) == 'miss' }
+            try {
+                Object[].metaClass.arrHello = { -> 'array-visible' }
+                assert probe(arr) == 'array-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+                GroovySystem.metaClassRegistry.removeMetaClass(String[])
+            }
+        '''
+    }
+
     /**
      * Blackdrag PR #2736 comment (corrected parent→child direction): hierarchy
      * walk is for <em>missing</em> methods only. Present methods on the child
@@ -355,8 +365,6 @@ final class Groovy12191 {
             class C extends A {
                 def m2(x) { 40 }
             }
-            // Declared at script level (not inside try): construction-time snapshot
-            // of A is taken when D's MetaClass is first built *after* A has EMC.
             class D extends A {
                 def m2(x) { 50 }
             }
@@ -374,17 +382,11 @@ final class Groovy12191 {
                 assert a.m2(0) == -2
                 assert a.m2('') == -3
 
-                // Present methods on B are unchanged (not replaced by parent EMC).
                 b.metaClass = null
                 assert b.m1() == 1
                 assert b.m2(0) == 2
-                // Missing m2(String) on B is found via hierarchy walk — first call,
-                // so no prior monomorphic site needed invalidation for this name.
                 assert b.m2('') == -3
 
-                // C was MetaClassImpl-initialized before A gained EMC methods:
-                // present m2(Object) handles Integer/String/null; parent overloads
-                // are not selected for this MetaClass instance.
                 assert c.m2(null) == 40
                 assert c.m2(0) == 40
                 assert c.m2('') == 40
@@ -393,15 +395,12 @@ final class Groovy12191 {
                 assert c.m2(0) == 40
                 assert c.m2('') == 40
 
-                // Promote C to EMC: missing-method / EMC inheritance sees A.
                 c.metaClass.m1 = { -> -40 }
                 assert c.m1() == -40
                 assert c.m2(null) == 40
                 assert c.m2(0) == -2
                 assert c.m2('') == -3
 
-                // D first used after A was modified: MetaClassImpl construction can
-                // snapshot ancestor expando methods (timing gap vs C).
                 def d = new D()
                 assert d.m2(null) == 50
                 assert d.m2(0) == -2
@@ -418,8 +417,8 @@ final class Groovy12191 {
     /**
      * Blackdrag callsite-caching matrix: receiver MetaClass change (exact class)
      * drives re-link; parent EMC remove does not rewrite a child MetaClassImpl
-     * snapshot; hierarchy SwitchPoint on parent EMC is for miss re-select, not
-     * for rebuilding MetaClassImpl tables.
+     * snapshot. Stock policy does not require hierarchy SwitchPoint for the
+     * miss path.
      */
     @Test
     void blackdragScenario_callsiteCaching_metaClassChangeNotHierarchyVersion() {
@@ -440,7 +439,6 @@ final class Groovy12191 {
             A.metaClass.m2 = { Integer x -> -2 }
             A.metaClass.m2 = { String x -> -3 }
             try {
-                // C MetaClassImpl from before / without snapshot of A overloads.
                 assert call(c, 0) == 40
                 c = new C()
                 assert call(c, 0) == 40
@@ -449,7 +447,6 @@ final class Groovy12191 {
                 assert mc instanceof MetaClassImpl
 
                 SwitchPoint spC = ClassInfo.getClassInfo(C).indySwitchPoint
-                // Receiver class becomes EMC → class-domain retire (MC changed).
                 c.metaClass.m1 = { -> -40 }
                 assert spC.hasBeenInvalidated()
                 assert call(c, 0) == -2
@@ -457,17 +454,12 @@ final class Groovy12191 {
                 def emc = c.metaClass.delegate
                 assert emc instanceof ExpandoMetaClass
 
-                // Restore old MetaClassImpl via registry (class-level replace).
-                // Instance-only metaClass= may clear a per-instance MC without a
-                // class-domain SwitchPoint event; class-level replace always does.
                 spC = ClassInfo.getClassInfo(C).indySwitchPoint
                 GroovySystem.metaClassRegistry.setMetaClass(C, mc)
                 c.metaClass = null
                 assert spC.hasBeenInvalidated()
                 assert call(c, 0) == 40
 
-                // Late init: remove C's MetaClass so a fresh MetaClassImpl is built
-                // while A still has EMC → construction-time snapshot sees A.
                 def registry = MetaClassRegistryImpl.getInstance(0)
                 spC = ClassInfo.getClassInfo(C).indySwitchPoint
                 registry.removeMetaClass(C)
@@ -478,11 +470,12 @@ final class Groovy12191 {
                 assert newMc instanceof MetaClassImpl
                 assert newMc != mc
 
-                // Reset A: hierarchy fans out and re-links, but child MetaClassImpl
-                // still holds the construction-time snapshot — SP does not rebuild MC.
+                // Reset A: stock exact-class — C's SwitchPoint is NOT retired.
+                // Child MetaClassImpl still holds the construction-time snapshot.
                 SwitchPoint spAfterA = ClassInfo.getClassInfo(C).indySwitchPoint
                 A.metaClass = null
-                assert spAfterA.hasBeenInvalidated()
+                assert !spAfterA.hasBeenInvalidated() :
+                        'parent EMC remove must not retire child SwitchPoint'
                 assert call(c, 0) == -2
 
                 def catched = false
@@ -493,11 +486,6 @@ final class Groovy12191 {
                 }
                 assert catched
 
-                // Re-install the EMC that was created while A still had methods.
-                // blackdrag's draft expected "clueless about A" → 40, but measured
-                // EMC retains ClosureMetaMethods copied from A (m2 Integer/String),
-                // so resolution stays -2 — similar retention to MetaClassImpl
-                // construction-time snapshot, not live super lookup.
                 spC = ClassInfo.getClassInfo(C).indySwitchPoint
                 GroovySystem.metaClassRegistry.setMetaClass(C, emc)
                 c.metaClass = null
@@ -511,8 +499,8 @@ final class Groovy12191 {
     }
 
     /**
-     * Hierarchy fan-out is required so a previously linked hit on a parent EMC
-     * method re-selects after that parent EMC is removed (miss path inverse).
+     * Linked hit via live miss route: after parent EMC is removed, the next
+     * call misses again — without requiring child SwitchPoint retirement.
      */
     @Test
     void parentEmcRemoveAfterLinkedHit_childCallSiteMissesAgain() {
@@ -529,7 +517,8 @@ final class Groovy12191 {
                 assert call(c) == 'from-parent'
                 SwitchPoint childSp = ClassInfo.getClassInfo(ChildHit).indySwitchPoint
                 GroovySystem.metaClassRegistry.removeMetaClass(ParentHit)
-                assert childSp.hasBeenInvalidated()
+                assert !childSp.hasBeenInvalidated() :
+                        'parent remove must not retire child SwitchPoint'
                 def missed = false
                 try {
                     call(c)
@@ -545,8 +534,8 @@ final class Groovy12191 {
     }
 
     /**
-     * Hierarchy fan-out is required so a previously linked miss on the child
-     * re-selects after parent EMC install (missing-method path).
+     * Linked miss then parent EMC add: live miss route observes the new method
+     * without child SwitchPoint retirement.
      */
     @Test
     void parentEmcAfterLinkedMiss_childCallSiteSeesNewMethod() {
@@ -569,7 +558,7 @@ final class Groovy12191 {
             SwitchPoint childSp = ClassInfo.getClassInfo(ChildMiss).indySwitchPoint
             ParentMiss.metaClass.hello = { -> 'from-parent' }
             try {
-                assert childSp.hasBeenInvalidated()
+                assert !childSp.hasBeenInvalidated()
                 assert call(c) == 'from-parent'
             } finally {
                 GroovySystem.metaClassRegistry.removeMetaClass(ParentMiss)
@@ -579,11 +568,11 @@ final class Groovy12191 {
     }
 
     /**
-     * Hierarchy SwitchPoint re-link must not change present-method resolution on
-     * a MetaClassImpl child when the parent later gains EMC overloads.
+     * Present child method must keep winning after parent EMC add (no SP
+     * fan-out needed; re-link would not change the outcome either).
      */
     @Test
-    void parentEmc_doesNotReplacePresentChildMethod_afterHierarchyRelink() {
+    void parentEmc_doesNotReplacePresentChildMethod() {
         assertScript '''
             import org.codehaus.groovy.reflection.ClassInfo
             import java.lang.invoke.SwitchPoint
@@ -599,8 +588,7 @@ final class Groovy12191 {
             SwitchPoint childSp = ClassInfo.getClassInfo(ChildPresent).indySwitchPoint
             ParentPresent.metaClass.m2 = { Integer x -> -2 }
             try {
-                assert childSp.hasBeenInvalidated() // hierarchy fan-out (over-invalidate OK)
-                // Present m2(Object) still wins for Integer after re-link.
+                assert !childSp.hasBeenInvalidated()
                 assert call(c, 0) == 40
             } finally {
                 GroovySystem.metaClassRegistry.removeMetaClass(ParentPresent)
@@ -610,13 +598,11 @@ final class Groovy12191 {
     }
 
     /**
-     * Pure Java inheritance matches Groovy for parent EMC visibility
-     * ({@code LinkedHashMap} extends {@code HashMap}): hierarchy fan-out is
-     * required so already-linked subtype sites re-select (blackdrag: same for
-     * Java-based classes).
+     * Pure Java inheritance: parent EMC visible on subtype via live hierarchy
+     * walk, without subtype SwitchPoint retirement.
      */
     @Test
-    void javaSubtype_seesParentEmc_andHierarchyFanOut() {
+    void javaSubtype_seesParentEmc_withoutHierarchyFanOut() {
         assertScript '''
             import org.codehaus.groovy.reflection.ClassInfo
             import java.lang.invoke.SwitchPoint
@@ -635,7 +621,7 @@ final class Groovy12191 {
             HashMap.metaClass.jHello = { -> 'jh' }
             try {
                 assert new HashMap().jHello() == 'jh'
-                assert lhmSp.hasBeenInvalidated()
+                assert !lhmSp.hasBeenInvalidated()
                 assert call(lhm) == 'jh'
             } finally {
                 GroovySystem.metaClassRegistry.removeMetaClass(HashMap)
@@ -671,9 +657,9 @@ final class Groovy12191 {
     static class BdA extends BdB {}
 
     /**
-     * Follow-up 1: SwitchPoint domain is owned by the MetaClass instance, not
-     * solely by ClassInfo. ClassInfo.getIndySwitchPoint delegates to the
-     * installed MetaClassImpl when present.
+     * SwitchPoint domain is owned by the MetaClass instance, not solely by
+     * ClassInfo. ClassInfo.getIndySwitchPoint delegates to the installed
+     * MetaClass when present.
      */
     @Test
     void metaClassOwnsSwitchPoint_singleDomainViaIdentityMap() {
@@ -687,7 +673,6 @@ final class Groovy12191 {
             info.strongMetaClass = mc
             assertSame(onMc, info.indySwitchPoint)
             assertSame(onMc, IndyInvalidation.classSwitchPointFor(McOwnerHost))
-            // Custom non-MetaClassImpl also uses the same map (identity domain).
             def custom = new groovy.lang.DelegatingMetaClass(mc) {}
             assertSame(onMc, IndyInvalidation.switchPointForMetaClass(custom))
         } finally {
@@ -696,13 +681,11 @@ final class Groovy12191 {
     }
 
     /**
-     * Follow-up 2 decision: MetaClassImpl continues to observe ancestor MetaClass
-     * state on the missing-method path ({@code Object.metaClass.foo} visible on
-     * subtypes). Hierarchy SwitchPoint fan-out remains necessary for linked miss
-     * → parent EMC add (and the inverse remove).
+     * MetaClassImpl continues to observe ancestor MetaClass state on the
+     * missing-method path ({@code Object.metaClass.foo} visible on subtypes).
      */
     @Test
-    void followUp2_metaClassImplStillObservesAncestorEmc_objectMetaClassPattern() {
+    void metaClassImplStillObservesAncestorEmc_objectMetaClassPattern() {
         assertScript '''
             class FollowUp2Child {}
             try {
@@ -717,40 +700,148 @@ final class Groovy12191 {
         '''
     }
 
+    // ------------------------------------------------------------------
+    // Property-site probes (blackdrag: "True we need to be sure the
+    // property case is covered." — PR #2736 follow-up)
+    // ------------------------------------------------------------------
+
     /**
-     * Precise hierarchy case blackdrag asked for: receiver MetaClass unchanged,
-     * parent EMC add must re-link an already-linked miss on the child.
+     * Linked property miss on child, then parent EMC property add: live property
+     * miss walk observes the new property without child SwitchPoint retirement.
      */
     @Test
-    void hierarchySwitchPoint_requiredOnlyForLinkedMissThenParentEmcAdd() {
+    void property_linkedMissThenParentEmcAdd_isVisible() {
         assertScript '''
             import org.codehaus.groovy.reflection.ClassInfo
             import java.lang.invoke.SwitchPoint
 
-            class HParent {}
-            class HChild extends HParent {}
-            def call(x) {
-                try {
-                    x.onlyOnParent()
-                    return 'hit'
-                } catch (MissingMethodException e) {
-                    return 'miss'
-                }
+            class PropParent {}
+            class PropChild extends PropParent {}
+            def probe(x) {
+                try { return x.onlyOnParentProp } catch (MissingPropertyException e) { return 'miss' }
+            }
+            def c = new PropChild()
+            200.times { assert probe(c) == 'miss' }
+            SwitchPoint childSp = ClassInfo.getClassInfo(PropChild).indySwitchPoint
+            try {
+                PropParent.metaClass.onlyOnParentProp = 'now-visible'
+                assert !childSp.hasBeenInvalidated() :
+                        'parent property EMC must not retire child SwitchPoint'
+                assert probe(c) == 'now-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParent)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChild)
+            }
+        '''
+    }
+
+    /**
+     * Linked property hit via live miss route, then parent EMC remove: next
+     * access misses again without child SwitchPoint retirement.
+     */
+    @Test
+    void property_parentEmcRemoveAfterLinkedHit_missesAgain() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class PropParentHit {}
+            class PropChildHit extends PropParentHit {}
+            def probe(x) {
+                try { return x.helloProp } catch (MissingPropertyException e) { return 'miss' }
+            }
+            def c = new PropChildHit()
+            PropParentHit.metaClass.helloProp = 'from-parent'
+            try {
+                assert probe(c) == 'from-parent'
+                SwitchPoint childSp = ClassInfo.getClassInfo(PropChildHit).indySwitchPoint
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentHit)
+                assert !childSp.hasBeenInvalidated()
+                assert probe(c) == 'miss'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentHit)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChildHit)
+            }
+        '''
+    }
+
+    /**
+     * Array-lattice <em>method</em> form of a property getter on Object[] is
+     * visible on String[] via the live miss route (same as
+     * {@link #linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible}). Plain
+     * {@code arr.foo} on a Java array is GPath collect, not MetaClass property
+     * miss — so property-style EMC on arrays is exercised through getXxx.
+     */
+    @Test
+    void propertyGetter_linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            def probe(x) {
+                try { return x.getArrOnlyProp() } catch (MissingMethodException e) { return 'miss' }
+            }
+            def arr = new String[0]
+            50.times { assert probe(arr) == 'miss' }
+            SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
+            try {
+                Object[].metaClass.getArrOnlyProp = { -> 'array-visible' }
+                assert !stringArraySp.hasBeenInvalidated()
+                assert probe(arr) == 'array-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+                GroovySystem.metaClassRegistry.removeMetaClass(String[])
+            }
+        '''
+    }
+
+    /**
+     * Present child property keeps winning after parent EMC adds a same-name
+     * property (construction-time / present-property selection).
+     */
+    @Test
+    void property_parentEmc_doesNotReplacePresentChildProperty() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class PropParentPresent {}
+            class PropChildPresent extends PropParentPresent {
+                def getM2() { 40 }
             }
-            def c = new HChild()
-            assert call(c) == 'miss' // monomorphic miss linked against child MetaClass
-            SwitchPoint childSp = ClassInfo.getClassInfo(HChild).indySwitchPoint
+            def probe(x) { x.m2 }
+            def c = new PropChildPresent()
+            assert probe(c) == 40
+
+            SwitchPoint childSp = ClassInfo.getClassInfo(PropChildPresent).indySwitchPoint
+            PropParentPresent.metaClass.m2 = -2
             try {
-                HParent.metaClass.onlyOnParent = { -> 'now-visible' }
-                assert childSp.hasBeenInvalidated()
-                assert call(c) == 'hit'
-                assert call(c) == 'hit'
+                assert !childSp.hasBeenInvalidated()
+                assert probe(c) == 40
             } finally {
-                GroovySystem.metaClassRegistry.removeMetaClass(HParent)
-                GroovySystem.metaClassRegistry.removeMetaClass(HChild)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentPresent)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChildPresent)
             }
         '''
     }
 
+    /**
+     * Exact-class property update on the receiver class itself must retire that
+     * class domain and make the property visible (same as methods).
+     */
+    @Test
+    void property_receiverEmcAdd_invalidatesOwnSwitchPointAndIsVisible() {
+        SwitchPoint sp = ClassInfo.getClassInfo(PropOwnHost).indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        PropOwnHost.metaClass.ownProp = 'yes'
+        try {
+            assertTrue(sp.hasBeenInvalidated())
+            assert new PropOwnHost().ownProp == 'yes'
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(PropOwnHost)
+        }
+    }
+
     static class McOwnerHost {}
+    static class PropOwnHost {}
 }
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/ClassHierarchyIndexTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/ClassHierarchyIndexTest.groovy
deleted file mode 100644
index 13bd897c9c..0000000000
--- a/src/test/groovy/org/apache/groovy/runtime/indy/ClassHierarchyIndexTest.groovy
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- *  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.
- */
-package org.apache.groovy.runtime.indy
-
-import org.codehaus.groovy.reflection.ClassInfo
-import org.junit.jupiter.api.Test
-
-import java.lang.invoke.SwitchPoint
-
-import static org.junit.jupiter.api.Assertions.assertFalse
-import static org.junit.jupiter.api.Assertions.assertTrue
-
-/**
- * Structural and integration tests for {@link ClassHierarchyIndex}.
- */
-final class ClassHierarchyIndexTest {
-
-    @Test
-    void collectStrictSupertypes_classHierarchy() {
-        def supers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(IndexChild, supers)
-        assertTrue(supers.contains(IndexParent))
-        assertTrue(supers.contains(Object))
-        assertTrue(supers.contains(IndexMarker))
-        assertFalse(supers.contains(IndexChild))
-        assertFalse(supers.contains(IndexSibling))
-    }
-
-    @Test
-    void collectStrictSupertypes_arrayCovariance() {
-        def supers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(String[], supers)
-        assertTrue(supers.contains(Object[]), 'Object[] is a covariant super of String[]')
-        assertTrue(supers.contains(CharSequence[]), 'CharSequence[] via component interfaces')
-        assertTrue(supers.contains(Serializable[]))
-        assertTrue(supers.contains(Object))
-        assertTrue(supers.contains(Cloneable))
-        assertTrue(supers.contains(Serializable))
-        assertFalse(supers.contains(String[]))
-        assertFalse(supers.contains(Integer[]))
-    }
-
-    @Test
-    void collectStrictSupertypes_multiDimArray() {
-        def supers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(String[][], supers)
-        assertTrue(supers.contains(Object[][]))
-        assertTrue(supers.contains(Object[]), 'String[][] <: Object[]')
-        assertTrue(supers.contains(CharSequence[][]))
-        assertFalse(supers.contains(CharSequence[]), 'String[][] is not <: CharSequence[]')
-        assertFalse(supers.contains(String[][]))
-    }
-
-    @Test
-    void collectStrictSupertypes_primitiveArray() {
-        def supers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(int[], supers)
-        assertTrue(supers.contains(Object))
-        assertTrue(supers.contains(Cloneable))
-        assertTrue(supers.contains(Serializable))
-        assertFalse(supers.contains(Object[]), 'int[] is not <: Object[]')
-        assertFalse(supers.contains(long[]))
-    }
-
-    @Test
-    void index_registersDescendantsUnderAncestors() {
-        // Force ClassInfo construction (registers in the index).
-        ClassInfo.getClassInfo(IndexParent)
-        ClassInfo.getClassInfo(IndexChild)
-        ClassInfo.getClassInfo(IndexSibling)
-        ClassInfo.getClassInfo(IndexMarkerImpl)
-
-        def underParent = []
-        ClassHierarchyIndex.collectDescendants(IndexParent, underParent)
-        assertTrue(underParent.any { it.theClass == IndexChild })
-        assertFalse(underParent.any { it.theClass == IndexSibling })
-
-        def underMarker = []
-        ClassHierarchyIndex.collectDescendants(IndexMarker, underMarker)
-        assertTrue(underMarker.any { it.theClass == IndexChild || it.theClass == IndexMarkerImpl },
-                'implementors indexed under IndexMarker')
-    }
-
-    @Test
-    void index_arrayDescendants_underObjectArrayAndInterfaceArray() {
-        ClassInfo.getClassInfo(String[])
-        ClassInfo.getClassInfo(Integer[])
-        ClassInfo.getClassInfo(Object[])
-
-        def underObjectArray = []
-        ClassHierarchyIndex.collectDescendants(Object[], underObjectArray)
-        assertTrue(underObjectArray.any { it.theClass == String[] })
-        assertTrue(underObjectArray.any { it.theClass == Integer[] })
-
-        def underCharSeqArray = []
-        ClassHierarchyIndex.collectDescendants(CharSequence[], underCharSeqArray)
-        assertTrue(underCharSeqArray.any { it.theClass == String[] })
-        assertFalse(underCharSeqArray.any { it.theClass == Integer[] })
-    }
-
-    @Test
-    void invalidateViaIndex_fansOutWithoutMissingDescendants() {
-        SwitchPoint parentSp = ClassInfo.getClassInfo(IndexFanParent).indySwitchPoint
-        SwitchPoint childSp = ClassInfo.getClassInfo(IndexFanChild).indySwitchPoint
-        SwitchPoint otherSp = ClassInfo.getClassInfo(IndexFanOther).indySwitchPoint
-
-        IndyInvalidation.invalidateClassHierarchy(IndexFanParent)
-
-        assertTrue(parentSp.hasBeenInvalidated())
-        assertTrue(childSp.hasBeenInvalidated())
-        assertFalse(otherSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateViaIndex_finalLeaf_emptyDescendantsStillRetiresRoot() {
-        // Finals have no indexed subtypes; single-path fan-out still retires the root.
-        SwitchPoint stringSp = ClassInfo.getClassInfo(String).indySwitchPoint
-        SwitchPoint siblingSp = ClassInfo.getClassInfo(IndexFanOther).indySwitchPoint
-        IndyInvalidation.invalidateClassHierarchy(String)
-        assertTrue(stringSp.hasBeenInvalidated())
-        assertFalse(siblingSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateViaIndex_primitive_emptyDescendantsStillRetiresRoot() {
-        SwitchPoint intSp = ClassInfo.getClassInfo(int).indySwitchPoint
-        SwitchPoint siblingSp = ClassInfo.getClassInfo(IndexFanOther).indySwitchPoint
-        IndyInvalidation.invalidateClassHierarchy(int)
-        assertTrue(intSp.hasBeenInvalidated())
-        assertFalse(siblingSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void collectStrictSupertypes_objectAndPrimitive_areEmpty() {
-        def objectSupers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(Object, objectSupers)
-        assertTrue(objectSupers.isEmpty())
-
-        def intSupers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(int, intSupers)
-        assertTrue(intSupers.isEmpty())
-
-        def nullSupers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(null, nullSupers)
-        assertTrue(nullSupers.isEmpty())
-    }
-
-    @Test
-    void collectDescendants_unknownAncestor_isEmpty() {
-        def out = []
-        ClassHierarchyIndex.collectDescendants(NeverRegisteredAncestor, out)
-        assertTrue(out.isEmpty())
-    }
-
-    @Test
-    void collectStrictSupertypes_interface_includesObject() {
-        def supers = new LinkedHashSet<Class<?>>()
-        ClassHierarchyIndex.collectStrictSupertypes(IndexMarker, supers)
-        assertTrue(supers.contains(Object))
-    }
-
-    // --- fixtures ---
-
-    private static class IndexParent {}
-    private static final class IndexChild extends IndexParent implements IndexMarker {}
-    private static final class IndexSibling {}
-    private static interface IndexMarker {}
-    private static final class IndexMarkerImpl implements IndexMarker {}
-
-    private static class IndexFanParent {}
-    private static final class IndexFanChild extends IndexFanParent {}
-    private static final class IndexFanOther {}
-    /** Never passed to ClassInfo.getClassInfo — no index entry. */
-    private static final class NeverRegisteredAncestor {}
-}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
index 4eea80b6ab..3cf15effd1 100644
--- a/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
@@ -20,6 +20,7 @@ package org.apache.groovy.runtime.indy
 
 import groovy.lang.ExpandoMetaClass
 import groovy.lang.GroovySystem
+import groovy.lang.MetaClass
 import org.codehaus.groovy.reflection.ClassInfo
 import org.codehaus.groovy.runtime.NullObject
 import org.junit.jupiter.api.BeforeEach
@@ -37,7 +38,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue
 
 /**
  * Unit tests for {@link IndyInvalidation} — MetaClass-owned SwitchPoint model
- * with MetaClass-aware exact-class vs hierarchy fan-out (GROOVY-12191).
+ * with exact-class stock policy and bulk invalidation for custom MetaClasses
+ * (GROOVY-12191).
  */
 final class IndyInvalidationTest {
 
@@ -74,7 +76,8 @@ final class IndyInvalidationTest {
     }
 
     @Test
-    void invalidateClass_fansOutToSubtypes() {
+    void invalidateClass_doesNotFanOutToSubtypes() {
+        // Stock policy: parent invalidation is exact-class only (PR #2736).
         SwitchPoint parentSp = ClassInfo.getClassInfo(Parent).indySwitchPoint
         SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
         SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
@@ -82,7 +85,8 @@ final class IndyInvalidationTest {
         IndyInvalidation.invalidateClass(Parent)
 
         assertTrue(parentSp.hasBeenInvalidated())
-        assertTrue(childSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'stock invalidateClass must not retire subtype SwitchPoints')
         assertFalse(siblingSp.hasBeenInvalidated())
     }
 
@@ -94,57 +98,39 @@ final class IndyInvalidationTest {
     }
 
     @Test
-    void invalidateClassHierarchy_finalClass_emptyDescendantsStillRetiresRoot() {
-        // Finals have no indexed subtypes; single-path fan-out still retires only the root.
-        SwitchPoint stringSp = ClassInfo.getClassInfo(String).indySwitchPoint
-        SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-        IndyInvalidation.invalidateClassHierarchy(String)
-        assertTrue(stringSp.hasBeenInvalidated())
-        assertFalse(siblingSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateClassHierarchy_objectArray_fansOutToReferenceArrays() {
-        // Arrays are final per Class.getModifiers(), but Object[] is still a
-        // MOP-relevant supertype of every reference array (GROOVY-12191 review).
+    void invalidateClass_objectArray_doesNotRetireStringArray() {
         SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
         SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
-        SwitchPoint integerArraySp = ClassInfo.getClassInfo(Integer[]).indySwitchPoint
         SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-        assertFalse(objectArraySp.hasBeenInvalidated())
-        assertFalse(stringArraySp.hasBeenInvalidated())
 
-        IndyInvalidation.invalidateClassHierarchy(Object[])
+        IndyInvalidation.invalidateClass(Object[])
 
         assertTrue(objectArraySp.hasBeenInvalidated())
-        assertTrue(stringArraySp.hasBeenInvalidated(), 'String[] must retire when Object[] domain is invalidated')
-        assertTrue(integerArraySp.hasBeenInvalidated(), 'Integer[] must retire when Object[] domain is invalidated')
+        assertFalse(stringArraySp.hasBeenInvalidated(),
+                'array lattice fan-out is not required; miss path is live')
         assertFalse(siblingSp.hasBeenInvalidated())
     }
 
     @Test
-    void invalidateClassHierarchy_stringArray_doesNotRetireUnrelatedArrays() {
-        // String[] has no array subtypes; sibling array types must stay live.
+    void invalidateClass_stringArray_doesNotRetireUnrelatedArrays() {
         SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
         SwitchPoint integerArraySp = ClassInfo.getClassInfo(Integer[]).indySwitchPoint
         SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
 
-        IndyInvalidation.invalidateClassHierarchy(String[])
+        IndyInvalidation.invalidateClass(String[])
 
         assertTrue(stringArraySp.hasBeenInvalidated())
         assertFalse(integerArraySp.hasBeenInvalidated())
-        assertFalse(objectArraySp.hasBeenInvalidated(), 'supertype Object[] must not be retired by String[] invalidation')
+        assertFalse(objectArraySp.hasBeenInvalidated())
     }
 
     @Test
-    void invalidateClassHierarchy_primitiveArray_retiresOnlyThatArrayType() {
-        // Primitive arrays are final and have no subtypes, but must not take the
-        // non-array final short-circuit incorrectly relative to Object[] handling.
+    void invalidateClass_primitiveArray_retiresOnlyThatArrayType() {
         SwitchPoint intArraySp = ClassInfo.getClassInfo(int[]).indySwitchPoint
         SwitchPoint longArraySp = ClassInfo.getClassInfo(long[]).indySwitchPoint
         SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
 
-        IndyInvalidation.invalidateClassHierarchy(int[])
+        IndyInvalidation.invalidateClass(int[])
 
         assertTrue(intArraySp.hasBeenInvalidated())
         assertFalse(longArraySp.hasBeenInvalidated())
@@ -152,55 +138,34 @@ final class IndyInvalidationTest {
     }
 
     @Test
-    void invalidateClass_objectArray_viaApi_fansOut() {
-        SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
-        IndyInvalidation.invalidateClass(Object[])
-        assertTrue(stringArraySp.hasBeenInvalidated())
+    void invalidateClass_interface_doesNotFanOutToImplementors() {
+        SwitchPoint ifaceSp = ClassInfo.getClassInfo(Marker).indySwitchPoint
+        SwitchPoint implSp = ClassInfo.getClassInfo(MarkerImpl).indySwitchPoint
+        SwitchPoint otherSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(Marker)
+
+        assertTrue(ifaceSp.hasBeenInvalidated())
+        assertFalse(implSp.hasBeenInvalidated())
+        assertFalse(otherSp.hasBeenInvalidated())
     }
 
     @Test
-    void guardWithMopSwitchPoints_stringArrayFallsBackOnObjectArrayInvalidate() {
+    void guardWithMopSwitchPoints_stringArrayFallsBackOnOwnInvalidateOnly() {
         def target = MethodHandles.constant(int, 11)
         def fallback = MethodHandles.constant(int, 12)
         def receiver = new String[0]
         def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
         assertEquals(11, guarded.invokeWithArguments())
 
+        // Parent array-type invalidation must not deopt String[] sites.
         IndyInvalidation.invalidateClass(Object[])
-        assertEquals(12, guarded.invokeWithArguments(),
-                'String[]-linked site must fall back after Object[] MetaClass-domain invalidation')
-    }
+        assertEquals(11, guarded.invokeWithArguments(),
+                'String[] site must stay linked when only Object[] domain retires')
 
-    @Test
-    void invalidateClassHierarchy_interfaceArray_fansOutToImplementorArrays() {
-        // Same final-array trap as Object[]: CharSequence[] is final yet
-        // assignable-from String[] (interface-array covariance).
-        SwitchPoint charSeqArraySp = ClassInfo.getClassInfo(CharSequence[]).indySwitchPoint
-        SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
-        SwitchPoint integerArraySp = ClassInfo.getClassInfo(Integer[]).indySwitchPoint
-
-        IndyInvalidation.invalidateClassHierarchy(CharSequence[])
-
-        assertTrue(charSeqArraySp.hasBeenInvalidated())
-        assertTrue(stringArraySp.hasBeenInvalidated(),
-                'String[] must retire when CharSequence[] domain is invalidated')
-        assertFalse(integerArraySp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateClassHierarchy_multiDimObjectArray_fansOut() {
-        // Multi-dim arrays are also final classes; Object[][] still fans out to String[][].
-        SwitchPoint object2dSp = ClassInfo.getClassInfo(Object[][]).indySwitchPoint
-        SwitchPoint string2dSp = ClassInfo.getClassInfo(String[][]).indySwitchPoint
-        SwitchPoint string1dSp = ClassInfo.getClassInfo(String[]).indySwitchPoint
-
-        IndyInvalidation.invalidateClassHierarchy(Object[][])
-
-        assertTrue(object2dSp.hasBeenInvalidated())
-        assertTrue(string2dSp.hasBeenInvalidated(),
-                'String[][] must retire when Object[][] domain is invalidated')
-        assertFalse(string1dSp.hasBeenInvalidated(),
-                '1-D String[] is not assignable-from Object[][]')
+        IndyInvalidation.invalidateClass(String[])
+        assertEquals(12, guarded.invokeWithArguments(),
+                'String[] site must fall back after its own domain retires')
     }
 
     @Test
@@ -212,7 +177,6 @@ final class IndyInvalidationTest {
 
         assertTrue(classSp.hasBeenInvalidated())
         assertEquals(catCount + 1, IndyInvalidation.categoryInvalidationCount())
-        // Fresh SP available after bulk retire
         SwitchPoint fresh = ClassInfo.getClassInfo(ClassA).indySwitchPoint
         assertFalse(fresh.hasBeenInvalidated())
     }
@@ -221,23 +185,54 @@ final class IndyInvalidationTest {
     void invalidateUnscoped_retiresLoadedClassSwitchPoints() {
         SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
         SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-        long before = IndyInvalidation.classInvalidationCount()
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        long classBefore = IndyInvalidation.classInvalidationCount()
         IndyInvalidation.invalidateUnscoped()
         assertTrue(spA.hasBeenInvalidated())
         assertTrue(spB.hasBeenInvalidated())
-        assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount(),
+                'unscoped is process-wide bulk (non-category)')
+        assertEquals(classBefore, IndyInvalidation.classInvalidationCount(),
+                'unscoped must not increment exact-class counter')
     }
 
     @Test
-    void classInfoIncVersion_invalidatesClassDomain_notSiblings() {
+    void invalidateBulk_retiresAllLoadedClassDomains() {
         SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
         SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-        int versionBefore = ClassInfo.getClassInfo(ClassA).version
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        long classBefore = IndyInvalidation.classInvalidationCount()
+        long catBefore = IndyInvalidation.categoryInvalidationCount()
 
-        ClassInfo.getClassInfo(ClassA).incVersion()
+        IndyInvalidation.invalidateBulk()
 
-        assertEquals(versionBefore + 1, ClassInfo.getClassInfo(ClassA).version)
         assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated())
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount())
+        assertEquals(classBefore, IndyInvalidation.classInvalidationCount())
+        assertEquals(catBefore, IndyInvalidation.categoryInvalidationCount())
+    }
+
+    @Test
+    void invalidateCategory_doesNotIncrementBulkCounter() {
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        IndyInvalidation.invalidateCategory()
+        assertEquals(bulkBefore, IndyInvalidation.bulkInvalidationCount(),
+                'category has its own counter; must not share bulk tally')
+    }
+
+    @Test
+    void classInfoIncVersion_invalidatesClassDomain_notSiblingsOrSubtypes() {
+        SwitchPoint spA = ClassInfo.getClassInfo(Parent).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        int versionBefore = ClassInfo.getClassInfo(Parent).version
+
+        ClassInfo.getClassInfo(Parent).incVersion()
+
+        assertEquals(versionBefore + 1, ClassInfo.getClassInfo(Parent).version)
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated())
         assertFalse(spB.hasBeenInvalidated())
     }
 
@@ -263,7 +258,6 @@ final class IndyInvalidationTest {
         def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
         assertEquals(1, guarded.invokeWithArguments())
 
-        // Category maps to bulk class-domain invalidation (single-guard model).
         IndyInvalidation.invalidateCategory()
         assertEquals(2, guarded.invokeWithArguments())
     }
@@ -292,90 +286,51 @@ final class IndyInvalidationTest {
     void resetCountersForTesting_zerosCounters() {
         IndyInvalidation.invalidateClass(ClassA)
         IndyInvalidation.invalidateCategory()
-        IndyInvalidation.invalidateClassExact(ClassB)
+        IndyInvalidation.invalidateBulk()
+        IndyInvalidation.invalidateUnscoped()
         assertTrue(IndyInvalidation.classInvalidationCount() > 0)
         assertTrue(IndyInvalidation.categoryInvalidationCount() > 0)
-        assertTrue(IndyInvalidation.hierarchyInvalidationCount() > 0)
-        assertTrue(IndyInvalidation.exactOnlyInvalidationCount() > 0)
+        assertTrue(IndyInvalidation.bulkInvalidationCount() > 0)
         IndyInvalidation.resetCountersForTesting()
         assertEquals(0, IndyInvalidation.classInvalidationCount())
         assertEquals(0, IndyInvalidation.categoryInvalidationCount())
-        assertEquals(0, IndyInvalidation.hierarchyInvalidationCount())
-        assertEquals(0, IndyInvalidation.exactOnlyInvalidationCount())
+        assertEquals(0, IndyInvalidation.bulkInvalidationCount())
     }
 
     @Test
-    void invalidateClassExact_doesNotFanOutToSubtypes() {
-        SwitchPoint parentSp = ClassInfo.getClassInfo(Parent).indySwitchPoint
-        SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
-        IndyInvalidation.invalidateClassExact(Parent)
-        assertTrue(parentSp.hasBeenInvalidated())
-        assertFalse(childSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void needsHierarchyFanOut_emcVsMetaClassImpl() {
+    void isStockMetaClass_andNeedsBulkInvalidation() {
         def mcImpl = new groovy.lang.MetaClassImpl(ClassA)
         mcImpl.initialize()
         def emc = new ExpandoMetaClass(ClassA, true, true)
         emc.initialize()
+        def custom = new PureCustomMetaClass(ClassA)
 
-        assertTrue(IndyInvalidation.isHierarchyLocalMetaClass(mcImpl))
-        assertTrue(IndyInvalidation.isHierarchyLocalMetaClass(null))
-        assertFalse(IndyInvalidation.isHierarchyLocalMetaClass(emc))
-
-        assertFalse(IndyInvalidation.needsHierarchyFanOut(ClassA, mcImpl, mcImpl),
-                'pure MetaClassImpl replace must not require fan-out')
-        assertFalse(IndyInvalidation.needsHierarchyFanOut(ClassA, null, mcImpl),
-                'first MetaClassImpl install must not require fan-out')
-        assertTrue(IndyInvalidation.needsHierarchyFanOut(ClassA, null, emc),
-                'EMC install must require fan-out')
-        assertTrue(IndyInvalidation.needsHierarchyFanOut(ClassA, emc, mcImpl),
-                'EMC remove must require fan-out')
-        assertTrue(IndyInvalidation.needsHierarchyFanOut(Marker, mcImpl, mcImpl),
-                'interface MetaClass change must require fan-out')
-        assertTrue(IndyInvalidation.needsHierarchyFanOut(String[], mcImpl, mcImpl),
-                'array MetaClass change must require fan-out')
-    }
-
-    @Test
-    void needsHierarchyFanOut_modifiedCustomMetaClass_fansOut() {
-        // MetaClassImpl subclass with isModified()==true is not hierarchy-local
-        // (same gate as findMethodInClassHierarchy).
-        def custom = new ModifiedCustomMetaClass(ClassA)
-        custom.initialize()
-        assertFalse(IndyInvalidation.isHierarchyLocalMetaClass(custom))
-        def mcImpl = new groovy.lang.MetaClassImpl(ClassA)
-        mcImpl.initialize()
-        assertTrue(IndyInvalidation.needsHierarchyFanOut(ClassA, mcImpl, custom),
-                'modified custom MetaClass must fan out (correctness-first)')
-    }
+        assertTrue(IndyInvalidation.isStockMetaClass(mcImpl))
+        assertTrue(IndyInvalidation.isStockMetaClass(null))
+        assertTrue(IndyInvalidation.isStockMetaClass(emc),
+                'EMC is stock MetaClassImpl; exact-class is sufficient')
+        assertFalse(IndyInvalidation.isStockMetaClass(custom),
+                'non-MetaClassImpl custom kinds require bulk')
 
-    @Test
-    void needsHierarchyFanOut_globalEmcEnabled() {
-        def mcImpl = new groovy.lang.MetaClassImpl(ClassA)
-        mcImpl.initialize()
-        try {
-            ExpandoMetaClass.enableGlobally()
-            assertTrue(IndyInvalidation.isGlobalEmcEnabled())
-            assertTrue(IndyInvalidation.needsHierarchyFanOut(ClassA, mcImpl, mcImpl),
-                    'global EMC must force hierarchy fan-out even for MetaClassImpl pair')
-        } finally {
-            ExpandoMetaClass.disableGlobally()
-            assertFalse(IndyInvalidation.isGlobalEmcEnabled())
-        }
+        assertFalse(IndyInvalidation.needsBulkInvalidation(mcImpl, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, emc),
+                'EMC install is stock → exact-class')
+        assertFalse(IndyInvalidation.needsBulkInvalidation(emc, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(mcImpl, mcImpl))
+        assertTrue(IndyInvalidation.needsBulkInvalidation(mcImpl, custom),
+                'custom MetaClass must bulk-invalidate')
+        assertTrue(IndyInvalidation.needsBulkInvalidation(custom, null))
     }
 
     @Test
-    void isExpandoMetaClass_detectsDirectAndWrapped() {
+    void isStockMetaClass_unwrapsHandleMetaClass() {
         def emc = new ExpandoMetaClass(ClassA, true, true)
         emc.initialize()
-        assertTrue(IndyInvalidation.isExpandoMetaClass(emc))
-        assertFalse(IndyInvalidation.isExpandoMetaClass(new groovy.lang.MetaClassImpl(ClassA)))
-        assertFalse(IndyInvalidation.isExpandoMetaClass(null))
         def handle = new org.codehaus.groovy.runtime.HandleMetaClass(emc)
-        assertTrue(IndyInvalidation.isExpandoMetaClass(handle),
-                'HandleMetaClass wrapping EMC must be recognised')
+        assertTrue(IndyInvalidation.isStockMetaClass(handle),
+                'HandleMetaClass wrapping EMC must unwrap to stock MetaClassImpl')
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, handle))
     }
 
     @Test
@@ -397,7 +352,8 @@ final class IndyInvalidationTest {
     }
 
     @Test
-    void invalidateForMetaClassChange_hierarchyForEmc() {
+    void invalidateForMetaClassChange_exactForEmc() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(PolicyParentEmc).indySwitchPoint
         SwitchPoint childSp = ClassInfo.getClassInfo(PolicyChildEmc).indySwitchPoint
         def emc = new ExpandoMetaClass(PolicyParentEmc, true, true)
         emc.initialize()
@@ -406,8 +362,26 @@ final class IndyInvalidationTest {
 
         IndyInvalidation.invalidateForMetaClassChange(event)
 
-        assertTrue(childSp.hasBeenInvalidated(),
-                'EMC install on parent must fan out to child')
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'EMC install is stock → exact-class, no subtype fan-out')
+    }
+
+    @Test
+    void invalidateForMetaClassChange_bulkForCustomMetaClass() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        def custom = new PureCustomMetaClass(ClassA)
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, null, ClassA, null, custom)
+
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        IndyInvalidation.invalidateForMetaClassChange(event)
+
+        assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated(),
+                'custom MetaClass must bulk-retire loaded class domains')
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount())
     }
 
     @Test
@@ -446,7 +420,6 @@ final class IndyInvalidationTest {
 
         def mc = new groovy.lang.MetaClassImpl(LocalGen)
         mc.initialize()
-        // Domain is per MetaClass identity; first install must not retire it.
         SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(mc)
         assertFalse(sp.hasBeenInvalidated())
         info.setStrongMetaClass(mc)
@@ -492,50 +465,13 @@ final class IndyInvalidationTest {
     }
 
     @Test
-    void invalidateClassHierarchy_interface_fansOutToImplementors() {
-        SwitchPoint ifaceSp = ClassInfo.getClassInfo(Marker).indySwitchPoint
-        SwitchPoint implSp = ClassInfo.getClassInfo(MarkerImpl).indySwitchPoint
-        SwitchPoint otherSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-
-        IndyInvalidation.invalidateClassHierarchy(Marker)
-
-        assertTrue(ifaceSp.hasBeenInvalidated())
-        assertTrue(implSp.hasBeenInvalidated())
-        assertFalse(otherSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateClassHierarchy_primitive_emptyDescendantsStillRetiresRoot() {
-        // Primitives have no indexed subtypes; single-path fan-out still retires the root.
-        SwitchPoint intSp = ClassInfo.getClassInfo(int).indySwitchPoint
-        SwitchPoint sibling = ClassInfo.getClassInfo(ClassB).indySwitchPoint
-        IndyInvalidation.invalidateClassHierarchy(int)
-        assertTrue(intSp.hasBeenInvalidated())
-        assertFalse(sibling.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateClassHierarchy_whenRootAlreadyDetached_stillFansOut() {
-        // Root detach returns null; subtypes still retire (covers rootSp == null branch).
-        def parentInfo = ClassInfo.getClassInfo(ParentAlreadyDetached)
-        def childInfo = ClassInfo.getClassInfo(ChildAlreadyDetached)
-        SwitchPoint parentSp = parentInfo.indySwitchPoint
-        SwitchPoint childSp = childInfo.indySwitchPoint
-        parentInfo.detachLiveIndySwitchPoint() // retire without invalidateAll
-        assertTrue(parentSp != null)
-        IndyInvalidation.invalidateClassHierarchy(ParentAlreadyDetached)
-        // parent was already detached (not necessarily invalidated); child must be retired
-        assertTrue(childSp.hasBeenInvalidated())
-    }
-
-    @Test
-    void invalidateAllLoaded_whenNoLiveSwitchPoints_isNoOpBatch() {
+    void bulkWhenNoLiveSwitchPoints_isSafeNoOp() {
         // Detach every tracked type used by this suite so the bulk batch is empty.
         ClassInfo.getClassInfo(ClassA).detachLiveIndySwitchPoint()
         ClassInfo.getClassInfo(ClassB).detachLiveIndySwitchPoint()
-        IndyInvalidation.invalidateAllLoadedClassSwitchPoints() // empty-batch early return
-        // still callable and safe
-        IndyInvalidation.invalidateAllLoadedClassSwitchPoints()
+        IndyInvalidation.invalidateBulk()
+        IndyInvalidation.invalidateCategory()
+        IndyInvalidation.invalidateUnscoped()
     }
 
     @Test
@@ -551,13 +487,12 @@ final class IndyInvalidationTest {
 
     @Test
     void statsLogging_enabled_inChildProcess() {
-        // STATS_LOG is fixed at class init; cover the fine-log branches in a fresh JVM.
         def javaBin = System.getProperty('java.home') + '/bin/java'
         def cp = System.getProperty('java.class.path')
         def pb = new ProcessBuilder(
                 javaBin,
                 '-Dgroovy.indy.invalidation.stats=true',
-                '-Djava.util.logging.config.file=', // allow programmatic FINE below via default
+                '-Djava.util.logging.config.file=',
                 '-cp', cp,
                 'org.apache.groovy.runtime.indy.IndyInvalidationStatsProbe')
         pb.redirectErrorStream(true)
@@ -567,20 +502,6 @@ final class IndyInvalidationTest {
         assertTrue(out.contains('OK'), out)
     }
 
-    @Test
-    void invalidateClassHierarchy_skipsClearedClassInfoWeakRefs() {
-        // Force a ClassInfo whose weak Class ref is cleared so the scan hits loaded == null.
-        def info = ClassInfo.getClassInfo(ClearedRefHost)
-        info.indySwitchPoint // ensure live
-        def field = ClassInfo.getDeclaredField('classRef')
-        field.accessible = true
-        def wr = (WeakReference) field.get(info)
-        wr.clear()
-        assertEquals(null, info.theClass)
-        // Must not throw; other ClassInfos still scanned.
-        IndyInvalidation.invalidateClassHierarchy(Parent)
-    }
-
     @Test
     void classInfoIncVersion_whenTheClassCleared_invalidatesLocally() {
         def info = ClassInfo.getClassInfo(ClearedIncVersionHost)
@@ -589,7 +510,7 @@ final class IndyInvalidationTest {
         field.accessible = true
         ((WeakReference) field.get(info)).clear()
         assertEquals(null, info.theClass)
-        info.incVersion() // else branch: invalidateIndySwitchPoint only
+        info.incVersion()
         assertTrue(sp.hasBeenInvalidated())
     }
 
@@ -597,7 +518,6 @@ final class IndyInvalidationTest {
     void setPerInstanceMetaClass_retiresClassSwitchPoint() {
         def type = Groovy12191PerInstHost
         def info = ClassInfo.getClassInfo(type)
-        // Construct before installing a custom class-level MetaClass.
         def instance = new Groovy12191PerInstHost()
         SwitchPoint sp = info.indySwitchPoint
         assertFalse(sp.hasBeenInvalidated())
@@ -608,7 +528,6 @@ final class IndyInvalidationTest {
         info.setPerInstanceMetaClass(instance, null)
     }
 
-
     @Test
     void hasClassLevelMetaClass_weakOnly_firstInstallDoesNotRetire() {
         def info = ClassInfo.getClassInfo(WeakOnlyHost)
@@ -617,7 +536,7 @@ final class IndyInvalidationTest {
         def mc = new groovy.lang.MetaClassImpl(WeakOnlyHost)
         mc.initialize()
         SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(mc)
-        info.setWeakMetaClass(mc) // first weak install
+        info.setWeakMetaClass(mc)
         assertFalse(sp.hasBeenInvalidated())
         assertSame(sp, info.indySwitchPoint)
         info.setWeakMetaClass(null)
@@ -642,7 +561,6 @@ final class IndyInvalidationTest {
         assertFalse(sp.hasBeenInvalidated())
         IndyInvalidation.invalidateMetaClass(mc)
         assertTrue(sp.hasBeenInvalidated())
-        // Fresh domain after retirement.
         assertFalse(IndyInvalidation.switchPointForMetaClass(mc).hasBeenInvalidated())
     }
 
@@ -656,7 +574,7 @@ final class IndyInvalidationTest {
         assertFalse(pending.hasBeenInvalidated())
         def mc = new groovy.lang.MetaClassImpl(type)
         mc.initialize()
-        info.strongMetaClass = mc // first install retires pending
+        info.strongMetaClass = mc
         assertTrue(pending.hasBeenInvalidated())
         SwitchPoint post = IndyInvalidation.classSwitchPointFor(type)
         assertSame(IndyInvalidation.switchPointForMetaClass(mc), post)
@@ -666,8 +584,6 @@ final class IndyInvalidationTest {
 
     @Test
     void classSwitchPointFor_doesNotForceMetaClassDuringLink() {
-        // Regression for script+closure defineClass order: classSwitchPointFor
-        // must not call getMetaClass() which loads nested classes early.
         def type = NoForceMcHost
         GroovySystem.metaClassRegistry.removeMetaClass(type)
         assertEquals(null, ClassInfo.getClassInfo(type).metaClassForClass)
@@ -682,23 +598,24 @@ final class IndyInvalidationTest {
         impl.initialize()
         def custom = new groovy.lang.DelegatingMetaClass(impl) {}
         SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(custom)
-        // Unwraps to adaptee MetaClassImpl — same domain key.
         assertSame(sp, IndyInvalidation.switchPointForMetaClass(impl))
         IndyInvalidation.invalidateMetaClass(impl)
         assertTrue(sp.hasBeenInvalidated())
     }
 
+    @Test
+    void invalidateMetaClass_null_isNoOp() {
+        IndyInvalidation.invalidateMetaClass(null)
+    }
+
     private static final class ClassA {}
     private static final class ClassB {}
     private static class Parent {}
     private static final class Child extends Parent {}
-    private static class ParentAlreadyDetached {}
-    private static final class ChildAlreadyDetached extends ParentAlreadyDetached {}
     private static final class LocalGen {}
     private static final class LocalGenReplace {}
     private static final class LocalGenWeak {}
     private static final class WeakOnlyHost {}
-    private static final class ClearedRefHost {}
     private static final class ClearedIncVersionHost {}
     private static interface Marker {}
     private static final class MarkerImpl implements Marker {}
@@ -715,10 +632,18 @@ final class IndyInvalidationTest {
 /** Top-level host for per-instance MetaClass SwitchPoint coverage. */
 class Groovy12191PerInstHost {}
 
-/** MetaClassImpl subclass that reports modified, forcing hierarchy fan-out. */
-class ModifiedCustomMetaClass extends groovy.lang.MetaClassImpl {
-    ModifiedCustomMetaClass(Class theClass) { super(theClass) }
-    @Override
-    boolean isModified() { true }
-}
+/**
+ * Pure custom MetaClass (not a {@code MetaClassImpl}) used to exercise bulk
+ * invalidation for non-stock kinds. {@code @Delegate} fills the MetaClass
+ * surface; {@link IndyInvalidation#isStockMetaClass} sees this type itself
+ * (not the adaptee), so it is classified as non-stock.
+ */
+class PureCustomMetaClass implements MetaClass {
+    @Delegate
+    final MetaClass delegate
 
+    PureCustomMetaClass(Class theClass) {
+        this.delegate = new groovy.lang.MetaClassImpl(theClass)
+        this.delegate.initialize()
+    }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
index 23477b02f3..37e2799517 100644
--- a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
@@ -40,7 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue
 final class IndyScopedSwitchPointTest {
 
     @Test
-    void registryListener_withClass_invalidatesHierarchy() {
+    void registryListener_withClass_invalidatesClassDomain() {
         // Ensure the MetaClass registry listener path (type != null) runs.
         SwitchPoint sp = ClassInfo.getClassInfo(RegistryHost).indySwitchPoint
         def mc = new ExpandoMetaClass(RegistryHost, true, true)
diff --git a/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
index 3e4822d117..1ff3bc7cb9 100644
--- a/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
+++ b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
@@ -41,6 +41,7 @@ public final class IndyInvalidationStatsProbe {
         }
 
         IndyInvalidation.invalidateClass(ProbeHost.class);
+        IndyInvalidation.invalidateBulk();
         IndyInvalidation.invalidateCategory();
         IndyInvalidation.invalidateUnscoped();
         System.out.println("OK");
diff --git a/subprojects/performance/README.adoc b/subprojects/performance/README.adoc
index 50f5d476aa..c6085a9baf 100644
--- a/subprojects/performance/README.adoc
+++ b/subprojects/performance/README.adoc
@@ -137,11 +137,12 @@ hit, GROOVY-11935), hence the two benches.
 == Scoped invalidation (GROOVY-12191)
 
 `ScopedInvalidationBench` measures per-class SwitchPoint scoping: unrelated
-MetaClass churn must not deoptimize a hot monomorphic site; same-type re-link,
-category enter/leave (bulk class-domain invalidation), and hierarchy fan-out
-must still force re-link. Steady-state monomorphic sites carry a single
-SwitchPoint guard. Complements the Grails-oriented `CallSiteInvalidationBench`
-under `org.apache.groovy.perf.grails`.
+MetaClass churn must not deoptimize a hot monomorphic site; same-type re-link
+and category enter/leave (bulk class-domain invalidation) must still force
+re-link; parent MetaClass churn must not deoptimize subclass sites (stock
+exact-class policy — no hierarchy fan-out). Steady-state monomorphic sites
+carry a single SwitchPoint guard. Complements the Grails-oriented
+`CallSiteInvalidationBench` under `org.apache.groovy.perf.grails`.
 
 == Dashboard pages
 
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
index 340109db61..a46754c777 100644
--- a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
@@ -43,16 +43,16 @@ import java.util.concurrent.TimeUnit
  * per-class SwitchPoint domain. Baseline / cross-type / same-type rows
  * intentionally overlap that bench so ratios are available under the same
  * {@code .bench.} include pattern; unique coverage here is category enter/leave
- * (bulk class-domain invalidation), hierarchy fan-out, and setup-isolated
- * post-burst steady state:
+ * (bulk class-domain invalidation), parent-vs-child isolation (stock exact-class
+ * — no hierarchy fan-out), and setup-isolated post-burst steady state:
  * <ul>
  *   <li><b>Per-class</b> — MetaClass changes on an unrelated type must not
  *       deoptimize a hot monomorphic site;</li>
  *   <li><b>Same-type</b> — MetaClass changes on the hot type must re-link;</li>
  *   <li><b>Category</b> — {@code use(Category)} enter/leave bulk-invalidates
  *       class SwitchPoints so category methods become visible;</li>
- *   <li><b>Hierarchy fan-out</b> — parent MetaClass changes invalidate
- *       subclass-linked sites.</li>
+ *   <li><b>Parent churn</b> — parent MetaClass changes must <em>not</em>
+ *       deoptimize subclass-linked sites (stock exact-class policy).</li>
  * </ul>
  * Compare {@link #hotLoop_afterUnrelatedBurst} / {@link #hotLoop_unrelatedMetaClassChurn}
  * against {@link #hotLoop_baseline} (should stay close after scoping) and against
@@ -90,14 +90,15 @@ class ScopedInvalidationBench {
     }
 
     /**
-     * Parent of {@link HierChild} for hierarchy fan-out churn.
+     * Parent of {@link HierChild} for parent-vs-child isolation churn.
      */
     static class HierParent {
         int base() { 1 }
     }
 
     /**
-     * Subclass receiver whose SwitchPoint must clear when the parent MetaClass changes.
+     * Subclass receiver whose SwitchPoint must stay live when only the parent
+     * MetaClass changes (stock exact-class policy).
      */
     static class HierChild extends HierParent {
         int leaf() { 2 }
@@ -194,8 +195,8 @@ class ScopedInvalidationBench {
     }
 
     /**
-     * Category enter/leave interleaved with hot calls — exercises the
-     * process-wide category SwitchPoint domain (never fails over).
+     * Category enter/leave interleaved with hot calls — exercises
+     * {@code invalidateCategory()} bulk retirement of loaded class domains.
      * Uses a modest number of {@code use} blocks so category re-link cost
      * is visible without dominating wall-clock.
      * @param bh blackhole for the accumulated result
@@ -215,12 +216,13 @@ class ScopedInvalidationBench {
     }
 
     /**
-     * Subclass receiver calls with periodic parent MetaClass changes (hierarchy
-     * fan-out must invalidate the child SwitchPoint).
+     * Subclass receiver calls with periodic parent MetaClass changes. Under
+     * stock exact-class policy this should stay close to
+     * {@link #parentChild_baseline} (parent churn must not deopt the child).
      * @param bh blackhole for the accumulated result
      */
     @Benchmark
-    void hierarchy_parentMetaClassChurn(Blackhole bh) {
+    void parentChild_parentMetaClassChurn(Blackhole bh) {
         int sum = 0
         for (int i = 0; i < ITERATIONS; i++) {
             sum += child.leaf()
@@ -233,11 +235,11 @@ class ScopedInvalidationBench {
     }
 
     /**
-     * Subclass receiver calls with no MetaClass mutation (hierarchy baseline).
+     * Subclass receiver calls with no MetaClass mutation (parent/child baseline).
      * @param bh blackhole for the accumulated result
      */
     @Benchmark
-    void hierarchy_baseline(Blackhole bh) {
+    void parentChild_baseline(Blackhole bh) {
         int sum = 0
         for (int i = 0; i < ITERATIONS; i++) {
             sum += child.leaf()