You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by mk...@apache.org on 2025/05/08 16:13:33 UTC

(jackrabbit-oak) branch OAK-11692_2 created (now 03411073bb)

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

mkataria pushed a change to branch OAK-11692_2
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git


      at 03411073bb OAK-11692: Added more tests in JsonUtilsTest

This branch includes the following new commits:

     new 118156b8f6 OAK-11692: Add inference config in oak (#2269) + add added more tests to JsonUtilsTest.java
     new 03411073bb OAK-11692: Added more tests in JsonUtilsTest

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(jackrabbit-oak) 01/02: OAK-11692: Add inference config in oak (#2269) + add added more tests to JsonUtilsTest.java

Posted by mk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

mkataria pushed a commit to branch OAK-11692_2
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit 118156b8f61df9ab56f8ebbc2559bc624f078dea
Author: Mohit Kataria <ti...@gmail.com>
AuthorDate: Thu May 8 21:32:24 2025 +0530

    OAK-11692: Add inference config in oak (#2269) + add added more tests to JsonUtilsTest.java
    
    This reverts commit a4d4cefe2286b16234b5f7026afc5243e6ed59a0 and added more tests to JsonUtilsTest.java
---
 .../oak/api/jmx/QueryEngineSettingsMBean.java      |  14 +
 .../jackrabbit/oak/api/jmx/package-info.java       |   2 +-
 .../main/java/org/apache/jackrabbit/oak/Oak.java   |  11 +
 .../jackrabbit/oak/query/QueryEngineSettings.java  |  19 +-
 .../oak/query/QueryEngineSettingsService.java      |  16 +
 .../oak/query/ast/FullTextSearchImpl.java          |  49 +-
 .../jackrabbit/oak/query/index/FilterImpl.java     |   5 +
 .../org/apache/jackrabbit/oak/osgi/OSGiIT.java     |   5 +
 oak-query-spi/pom.xml                              |   9 +
 .../apache/jackrabbit/oak/spi/query/Filter.java    |   8 +
 .../jackrabbit/oak/spi/query/QueryLimits.java      |   4 +
 .../spi/query/fulltext/InferenceQueryConfig.java   |  53 ++
 .../oak/spi/query/fulltext/VectorQuery.java        | 100 +++
 .../oak/spi/query/fulltext/package-info.java       |   2 +-
 .../jackrabbit/oak/spi/query/package-info.java     |   2 +-
 .../spi/query/fulltext/VectorQueryConfigTest.java  |  55 ++
 .../oak/spi/query/fulltext/VectorQueryTest.java    |  92 +++
 oak-search-elastic/pom.xml                         |  12 +
 .../elastic/query/inference/EnricherStatus.java    |  81 +++
 .../elastic/query/inference/InferenceConfig.java   | 238 +++++++
 .../query/inference/InferenceConstants.java        |  33 +
 .../query/inference/InferenceHeaderPayload.java    |  70 ++
 .../query/inference/InferenceIndexConfig.java      | 125 ++++
 .../query/inference/InferenceModelConfig.java      | 176 +++++
 .../elastic/query/inference/InferencePayload.java  |  79 +++
 .../util/EnvironmentVariableProcessorUtil.java     |  54 ++
 .../query/inference/InferenceConfigTest.java       | 786 +++++++++++++++++++++
 .../elastic/query/inference/VectorDocument.java    |  56 ++
 .../it/BackwardCompatibleMountCompositeIT.java     |   1 +
 .../oak/composite/it/CompositeTestSupport.java     |   6 +-
 oak-store-spi/pom.xml                              |   5 +
 .../org/apache/jackrabbit/oak/json/JsonUtils.java  | 281 ++++++++
 .../oak/json/JsonUtilsNodeStateToMapTest.java      | 248 +++++++
 .../apache/jackrabbit/oak/json/JsonUtilsTest.java  | 270 +++++++
 34 files changed, 2942 insertions(+), 25 deletions(-)

diff --git a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/QueryEngineSettingsMBean.java b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/QueryEngineSettingsMBean.java
index df9a550029..93bfc9888c 100644
--- a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/QueryEngineSettingsMBean.java
+++ b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/QueryEngineSettingsMBean.java
@@ -107,6 +107,20 @@ public interface QueryEngineSettingsMBean {
      */
     void setFailTraversal(boolean failTraversal);
 
+    /**
+     * Get whether the query engine will parse the query and infers inference config
+     *
+     * @return true if inference is enabled
+     */
+    boolean isInferenceEnabled();
+
+    /**
+     * Set whether to parse for inference index config to use from query.
+     *
+     * @param isInferenceEnabled the new value for this setting
+     */
+    void setInferenceEnabled(boolean isInferenceEnabled);
+
     /**
      * Whether the query result size should return an estimation for large queries.
      *
diff --git a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java
index 43d162d725..678163b257 100644
--- a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java
+++ b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-@Version("4.13.0")
+@Version("4.14.0")
 package org.apache.jackrabbit.oak.api.jmx;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
index b1eb93ec9b..7009a89399 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/Oak.java
@@ -411,6 +411,7 @@ public class Oak {
         this.queryEngineSettings.setLimitInMemory(settings.getLimitInMemory());
         this.queryEngineSettings.setLimitReads(settings.getLimitReads());
         this.queryEngineSettings.setStrictPathRestriction(settings.getStrictPathRestriction());
+        this.queryEngineSettings.setInferenceEnabled(settings.isInferenceEnabled());
         return this;
     }
 
@@ -957,6 +958,16 @@ public class Oak {
             settings.setFailTraversal(failQueriesWithoutIndex);
         }
 
+        @Override
+        public boolean isInferenceEnabled() {
+            return settings.isInferenceEnabled();
+        }
+
+        @Override
+        public void setInferenceEnabled(boolean isInferenceEnabled) {
+            settings.setInferenceEnabled(isInferenceEnabled);
+        }
+
         @Override
         public boolean isFastQuerySize() {
             return settings.isFastQuerySize();
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
index bb90eb2ad9..9527ddb927 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettings.java
@@ -75,7 +75,12 @@ public class QueryEngineSettings implements QueryEngineSettingsMBean, QueryLimit
 
     private static final boolean DEFAULT_FULL_TEXT_COMPARISON_WITHOUT_INDEX =
             Boolean.getBoolean("oak.queryFullTextComparisonWithoutIndex");
-    
+
+    public static final String OAK_INFERENCE_ENABLED = "oak.query.InferenceEnabled";
+    private static final boolean DEFAULT_INFERENCE_ENABLED =
+            Boolean.getBoolean(OAK_INFERENCE_ENABLED);
+
+
     private long limitInMemory = DEFAULT_QUERY_LIMIT_IN_MEMORY;
     
     private long limitReads = DEFAULT_QUERY_LIMIT_READS;
@@ -86,6 +91,8 @@ public class QueryEngineSettings implements QueryEngineSettingsMBean, QueryLimit
     
     private boolean fullTextComparisonWithoutIndex = 
             DEFAULT_FULL_TEXT_COMPARISON_WITHOUT_INDEX;
+
+    private boolean isInferenceEnabled = DEFAULT_INFERENCE_ENABLED;
     
     private boolean sql2Optimisation = 
             Boolean.parseBoolean(System.getProperty(SQL2_OPTIMISATION_FLAG, "true"));
@@ -200,6 +207,16 @@ public class QueryEngineSettings implements QueryEngineSettingsMBean, QueryLimit
         this.failTraversal = failTraversal;
     }
 
+    @Override
+    public boolean isInferenceEnabled() {
+        return this.isInferenceEnabled;
+    }
+
+    @Override
+    public void setInferenceEnabled(boolean isInferenceEnabled) {
+        this.isInferenceEnabled = isInferenceEnabled;
+    }
+
     @Override
     public boolean isFastQuerySize() {
         return fastQuerySize;
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettingsService.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettingsService.java
index f52542a991..f30b3d5c3e 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettingsService.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/QueryEngineSettingsService.java
@@ -86,6 +86,12 @@ public class QueryEngineSettingsService {
                 )
         String[] ignoredClassNamesInCallTrace() default {};
 
+        @AttributeDefinition(
+                name = "Enable inference query parsing",
+                description = "If enabled the query engine will parse the query, infers the inference index config from query"
+        )
+        boolean isInferenceEnabled() default DEFAULT_IS_INFERENCE_ENABLED;
+
     }
 
     // should be the same as QueryEngineSettings.DEFAULT_QUERY_LIMIT_IN_MEMORY
@@ -102,6 +108,9 @@ public class QueryEngineSettingsService {
     static final String QUERY_FAST_QUERY_SIZE = "fastQuerySize";
     static final String DISABLED_STRICT_PATH_RESTRICTION = "DISABLE";
 
+    private static final boolean DEFAULT_IS_INFERENCE_ENABLED = false;
+    static final String INFERENCE_ENABLED = "inferenceEnabled";
+
     private final Logger log = LoggerFactory.getLogger(getClass());
 
     @Reference
@@ -130,6 +139,13 @@ public class QueryEngineSettingsService {
             logMsg(QUERY_FAIL_TRAVERSAL, QueryEngineSettings.OAK_QUERY_FAIL_TRAVERSAL);
         }
 
+        if (System.getProperty(QueryEngineSettings.OAK_INFERENCE_ENABLED) == null) {
+            boolean isInferenceEnabled = config.isInferenceEnabled();
+            queryEngineSettings.setInferenceEnabled(isInferenceEnabled);
+        } else {
+            logMsg(INFERENCE_ENABLED, QueryEngineSettings.OAK_INFERENCE_ENABLED);
+        }
+
         queryEngineSettings.setIgnoredClassNamesInCallTrace(config.ignoredClassNamesInCallTrace());
 
         boolean fastQuerySizeSysProp = QueryEngineSettings.DEFAULT_FAST_QUERY_SIZE;
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/ast/FullTextSearchImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/ast/FullTextSearchImpl.java
index 6b3fa3f4e9..2f5c88c57c 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/ast/FullTextSearchImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/ast/FullTextSearchImpl.java
@@ -18,24 +18,25 @@
  */
 package org.apache.jackrabbit.oak.query.ast;
 
-import static org.apache.jackrabbit.oak.api.Type.STRING;
-import static org.apache.jackrabbit.oak.api.Type.STRINGS;
-
-import java.text.ParseException;
-import java.util.Collections;
-import java.util.Set;
-
 import org.apache.jackrabbit.oak.api.PropertyState;
 import org.apache.jackrabbit.oak.api.PropertyValue;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.api.Type;
 import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
+import org.apache.jackrabbit.oak.query.index.FilterImpl;
+import org.apache.jackrabbit.oak.spi.query.QueryIndex.FulltextQueryIndex;
 import org.apache.jackrabbit.oak.spi.query.fulltext.FullTextContains;
 import org.apache.jackrabbit.oak.spi.query.fulltext.FullTextExpression;
 import org.apache.jackrabbit.oak.spi.query.fulltext.FullTextParser;
-import org.apache.jackrabbit.oak.query.index.FilterImpl;
-import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
-import org.apache.jackrabbit.oak.spi.query.QueryIndex.FulltextQueryIndex;
+import org.apache.jackrabbit.oak.spi.query.fulltext.VectorQuery;
+
+import java.text.ParseException;
+import java.util.Collections;
+import java.util.Set;
+
+import static org.apache.jackrabbit.oak.api.Type.STRING;
+import static org.apache.jackrabbit.oak.api.Type.STRINGS;
 
 /**
  * A fulltext "contains(...)" condition.
@@ -69,7 +70,7 @@ public class FullTextSearchImpl extends ConstraintImpl {
         } else {
             this.propertyName = propertyName;
         }
-        
+
         this.fullTextSearchExpression = fullTextSearchExpression;
     }
 
@@ -137,17 +138,27 @@ public class FullTextSearchImpl extends ConstraintImpl {
             }
             String p2 = normalizePropertyName(p);
             String rawText = getRawText(v);
-            FullTextExpression e = FullTextParser.parse(p2, rawText);
+            String queryText = rawText;
+            // To use inference we need to add inferenceconfig information in query. The format for the query is
+            // <inferencePrefix><json with inferenceModelConfig><inferencePrefix>
+            // e.g. ?{"inferenceModelConfig": "ada-test-model"}?little red fox
+            // So here we split the query into text part of query and inferenceConfig part of query.
+            // Afterwards we only parse text part of query as this part of query is what we want to search.
+            if (query.getSettings().isInferenceEnabled()) {
+                VectorQuery vectorQuery = new VectorQuery(rawText);
+                queryText = vectorQuery.getQueryText();
+            }
+            FullTextExpression e = FullTextParser.parse(p2, queryText);
             return new FullTextContains(p2, rawText, e);
         } catch (ParseException e) {
             throw new IllegalArgumentException("Invalid expression: " + fullTextSearchExpression, e);
         }
     }
-    
+
     String getRawText(PropertyValue v) {
         return v.getValue(Type.STRING);
     }
-    
+
     @Override
     public Set<SelectorImpl> getSelectors() {
         return Collections.singleton(selector);
@@ -155,9 +166,9 @@ public class FullTextSearchImpl extends ConstraintImpl {
 
     /**
      * verify that a property exists in the node. {@code property IS NOT NULL}
-     * 
+     *
      * @param propertyName the property to check
-     * @param selector the selector to work with
+     * @param selector     the selector to work with
      * @return true if the property is there, false otherwise.
      */
     boolean enforcePropertyExistence(String propertyName, SelectorImpl selector) {
@@ -167,7 +178,7 @@ public class FullTextSearchImpl extends ConstraintImpl {
         }
         return true;
     }
-    
+
     @Override
     public boolean evaluate() {
         // disable evaluation if a fulltext index is used,
@@ -225,7 +236,7 @@ public class FullTextSearchImpl extends ConstraintImpl {
         }
         return getFullTextConstraint(selector).evaluate(buff.toString());
     }
-    
+
     @Override
     public boolean evaluateStop() {
         // if a fulltext index is used, then we are fine
@@ -278,7 +289,7 @@ public class FullTextSearchImpl extends ConstraintImpl {
     /**
      * restrict the provided property to the property to the provided filter achieving so
      * {@code property IS NOT NULL}
-     * 
+     *
      * @param propertyName
      * @param f
      */
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/index/FilterImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/index/FilterImpl.java
index 6995c0e418..a0d41c1e6f 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/query/index/FilterImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/query/index/FilterImpl.java
@@ -257,6 +257,11 @@ public class FilterImpl implements Filter {
         return alwaysFalse;
     }
 
+    @Override
+    public boolean isInferenceEnabled() {
+        return getQueryLimits().isInferenceEnabled();
+    }
+
     public SelectorImpl getSelector() {
         return selector;
     }
diff --git a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
index 7d814496c0..cffaa55159 100644
--- a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
+++ b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/OSGiIT.java
@@ -71,6 +71,11 @@ public class OSGiIT {
                 mavenBundle( "org.apache.felix", "org.apache.felix.configadmin", "1.9.20" ),
                 mavenBundle( "org.apache.felix", "org.apache.felix.fileinstall", "3.2.6" ),
                 mavenBundle( "org.ops4j.pax.logging", "pax-logging-api", "1.7.2" ),
+                // Jackson dependency for object serialisation.
+                mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-core").version("2.17.2"),
+                mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-annotations").version("2.17.2"),
+                mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-databind").version("2.17.2"),
+
                 frameworkProperty("repository.home").value("target"),
                 systemProperties(new SystemPropertyOption("felix.fileinstall.dir").value(getConfigDir())),
                 jarBundles(),
diff --git a/oak-query-spi/pom.xml b/oak-query-spi/pom.xml
index d23ee1ed35..941d8a1906 100644
--- a/oak-query-spi/pom.xml
+++ b/oak-query-spi/pom.xml
@@ -82,6 +82,15 @@
       <artifactId>oak-store-spi</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+      <version>${jackson.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-lang3</artifactId>
+    </dependency>
 
     <!-- Nullability annotations -->
     <dependency>
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/Filter.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/Filter.java
index 83b4ff52c8..848b445822 100644
--- a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/Filter.java
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/Filter.java
@@ -179,6 +179,14 @@ public interface Filter {
      */
     boolean isAlwaysFalse();
 
+    /**
+     *
+     * @return true if inference is enabled.
+     */
+    default boolean isInferenceEnabled() {
+        return false;
+    }
+
     /**
      * A restriction for a property.
      */
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/QueryLimits.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/QueryLimits.java
index d06edaf520..050e1bf9da 100644
--- a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/QueryLimits.java
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/QueryLimits.java
@@ -64,4 +64,8 @@ public interface QueryLimits {
         return new String[] {};
     }
 
+    default boolean isInferenceEnabled(){
+        return false;
+    };
+
 }
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/InferenceQueryConfig.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/InferenceQueryConfig.java
new file mode 100644
index 0000000000..3b85d3bc93
--- /dev/null
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/InferenceQueryConfig.java
@@ -0,0 +1,53 @@
+/*
+ * 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.jackrabbit.oak.spi.query.fulltext;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public class InferenceQueryConfig {
+    public static final String TYPE = "inferenceModelConfig";
+    @Nullable
+    private final String inferenceModelConfig;
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    public InferenceQueryConfig(@NotNull String queryConfig) {
+        if (queryConfig.isBlank()){
+            this.inferenceModelConfig = null;
+        } else if (queryConfig.equals("{}")) {
+            // in this case a default inferenceModelConfig will be used.
+            this.inferenceModelConfig = "";
+        } else {
+            try {
+                JsonNode jsonNode1 = objectMapper.readTree(queryConfig);
+                inferenceModelConfig = jsonNode1.get(TYPE).asText();
+            } catch (JsonProcessingException e) {
+                throw new RuntimeException("Error parsing inference query config: "+ queryConfig  + "error message: " + e.getMessage());
+            }
+        }
+    }
+
+    public @Nullable String getInferenceModelConfig() {
+        return inferenceModelConfig;
+    }
+}
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQuery.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQuery.java
new file mode 100644
index 0000000000..7079b73f35
--- /dev/null
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQuery.java
@@ -0,0 +1,100 @@
+/*
+ * 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.jackrabbit.oak.spi.query.fulltext;
+
+import org.apache.jackrabbit.oak.json.JsonUtils;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class VectorQuery {
+    private static final Logger LOG = LoggerFactory.getLogger(VectorQuery.class);
+    private static final String DEFAULT_INFERENCE_QUERY_CONFIG_PREFIX = "?";
+    private static final String INFERENCE_QUERY_CONFIG_PREFIX_KEY = "org.apache.jackrabbit.oak.search.inference.query.prefix";
+    public static final String INFERENCE_QUERY_CONFIG_PREFIX = System.getProperty(
+            INFERENCE_QUERY_CONFIG_PREFIX_KEY, DEFAULT_INFERENCE_QUERY_CONFIG_PREFIX);
+
+    private final String queryInferenceConfig;
+    private final String queryText;
+
+    public VectorQuery(@NotNull String text) {
+        String[] components = parseText(text);
+        this.queryInferenceConfig = components[0];
+        this.queryText = components[1];
+    }
+
+    private String[] parseText(String inputText) {
+        String text = inputText.trim();
+        // Remove the first delimiter
+        if (text.startsWith(INFERENCE_QUERY_CONFIG_PREFIX) && text.charAt(INFERENCE_QUERY_CONFIG_PREFIX.length()) == '{') {
+            text = text.substring(INFERENCE_QUERY_CONFIG_PREFIX.length());
+
+            // Try to find the end of the JSON part by parsing incrementally
+            int possibleEndIndex = 0;
+            String jsonPart = null;
+            String queryTextPart;
+            int jsonEndDelimiterIndex = -1;
+
+            while (possibleEndIndex < text.length()) {
+                possibleEndIndex = text.indexOf(INFERENCE_QUERY_CONFIG_PREFIX, possibleEndIndex + 1);
+                if (possibleEndIndex == -1) {
+                    // If we reach here, it means we couldn't find a valid JSON part
+                    jsonPart = "";
+                    LOG.warn("Query starts with inference prefix {}, but without valid json part," +
+                                    " if case this prefix is a valid fulltext query prefix, please update system property {} with different prefix value",
+                            INFERENCE_QUERY_CONFIG_PREFIX, INFERENCE_QUERY_CONFIG_PREFIX_KEY);
+                    break;
+                }
+                String candidateJson = text.substring(0, possibleEndIndex);
+                // Verify if this is valid JSON using Oak's JsopTokenizer
+                if (JsonUtils.isValidJson(candidateJson, false)) {
+                    jsonPart = candidateJson;
+                    jsonEndDelimiterIndex = possibleEndIndex;
+                    break;
+                }
+            }
+            // If we found a valid JSON part, extract it
+            if (jsonPart == null) {
+                // If we reach here, it means we couldn't find a valid JSON part
+                jsonPart = "";
+                queryTextPart = text;
+                LOG.warn("Query starts with InferenceQueryPrefix: {}, but without valid json part," +
+                                " if case this prefix is a valid fulltext query prefix, please update {} with different prefix value",
+                        INFERENCE_QUERY_CONFIG_PREFIX, INFERENCE_QUERY_CONFIG_PREFIX_KEY);
+
+            } else {
+                // Extract query text part (everything after the JSON part delimiter)
+                queryTextPart = text.substring(jsonEndDelimiterIndex + 1).trim();
+
+            }
+            return new String[]{jsonPart, queryTextPart};
+        } else {
+            return new String[]{"", text};
+        }
+    }
+
+    public String getQueryInferenceConfig() {
+        return queryInferenceConfig;
+    }
+
+    public String getQueryText() {
+        return queryText;
+    }
+}
\ No newline at end of file
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/package-info.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/package-info.java
index 324e2d104a..9e851fadb9 100644
--- a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/package-info.java
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/fulltext/package-info.java
@@ -18,7 +18,7 @@
 /**
  * This package contains fulltext search condition implementations.
  */
-@Version("1.0.0")
+@Version("1.1.0")
 package org.apache.jackrabbit.oak.spi.query.fulltext;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/package-info.java b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/package-info.java
index 2cdf053fcc..200a10dc57 100644
--- a/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/package-info.java
+++ b/oak-query-spi/src/main/java/org/apache/jackrabbit/oak/spi/query/package-info.java
@@ -18,7 +18,7 @@
 /**
  * This package contains oak query index related classes.
  */
-@Version("3.1.0")
+@Version("3.2.0")
 package org.apache.jackrabbit.oak.spi.query;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryConfigTest.java b/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryConfigTest.java
new file mode 100644
index 0000000000..68dcf6bad3
--- /dev/null
+++ b/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryConfigTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.jackrabbit.oak.spi.query.fulltext;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class VectorQueryConfigTest {
+
+    @Test
+    public void testEmptyConfig() {
+        InferenceQueryConfig config = new InferenceQueryConfig("");
+        assertNull(config.getInferenceModelConfig());
+    }
+
+    @Test
+    public void testEmptyJsonConfig() {
+        InferenceQueryConfig config = new InferenceQueryConfig("{}");
+        assertEquals("", config.getInferenceModelConfig());
+    }
+
+    @Test
+    public void testValidConfig() {
+        InferenceQueryConfig config = new InferenceQueryConfig("{\"inferenceModelConfig\":\"ada-test-model\"}");
+        assertEquals("ada-test-model", config.getInferenceModelConfig());
+    }
+
+    @Test(expected = RuntimeException.class)
+    public void testInvalidJsonConfig() {
+        new InferenceQueryConfig("{invalid json}");
+    }
+
+    @Test(expected = RuntimeException.class)
+    public void testMissingTypeConfig() {
+        new InferenceQueryConfig("{\"someOtherField\":\"value\"}");
+    }
+}
diff --git a/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryTest.java b/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryTest.java
new file mode 100644
index 0000000000..ecc95db102
--- /dev/null
+++ b/oak-query-spi/src/test/java/org/apache/jackrabbit/oak/spi/query/fulltext/VectorQueryTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.jackrabbit.oak.spi.query.fulltext;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class VectorQueryTest {
+
+    @Test
+    public void testBasicQuery() {
+        VectorQuery query = new VectorQuery("simple query");
+        assertEquals("", query.getQueryInferenceConfig());
+        assertEquals("simple query", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithInferenceConfig() {
+        VectorQuery query = new VectorQuery("?{\"model\":\"gpt-4\"}?search for oak trees");
+        assertEquals("{\"model\":\"gpt-4\"}", query.getQueryInferenceConfig());
+        assertEquals("search for oak trees", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithComplexInferenceConfig() {
+        VectorQuery query = new VectorQuery(
+            "?{\"model\":\"gpt-4\",\"temperature\":0.7,\"options\":{\"filter\":true}}?oak trees");
+        assertEquals("{\"model\":\"gpt-4\",\"temperature\":0.7,\"options\":{\"filter\":true}}",
+            query.getQueryInferenceConfig());
+        assertEquals("oak trees", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithQuestionMarksInText() {
+        VectorQuery query = new VectorQuery("?{\"model\":\"gpt-4\"}?what are oak trees?");
+        assertEquals("{\"model\":\"gpt-4\"}", query.getQueryInferenceConfig());
+        assertEquals("what are oak trees?", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithoutInferencePrefix() {
+        VectorQuery query = new VectorQuery("{\"model\":\"gpt-4\"}?query");
+        assertEquals("", query.getQueryInferenceConfig());
+        assertEquals("{\"model\":\"gpt-4\"}?query", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithInvalidJson() {
+        VectorQuery query = new VectorQuery("?{invalid json}?query");
+        assertEquals("", query.getQueryInferenceConfig());
+        assertEquals("{invalid json}?query", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithEmptyConfig() {
+        VectorQuery query = new VectorQuery("??query text");
+        assertEquals("", query.getQueryInferenceConfig());
+        assertEquals("??query text", query.getQueryText());
+    }
+
+    @Test
+    public void testQueryWithWhitespace() {
+        VectorQuery query = new VectorQuery("   ?{\"model\":\"gpt-4\"}?   search query   ");
+        assertEquals("{\"model\":\"gpt-4\"}", query.getQueryInferenceConfig());
+        assertEquals("search query", query.getQueryText());
+    }
+
+    @Test
+    public void testEmptyQuery() {
+        VectorQuery query = new VectorQuery("");
+        assertEquals("", query.getQueryInferenceConfig());
+        assertEquals("", query.getQueryText());
+    }
+
+}
\ No newline at end of file
diff --git a/oak-search-elastic/pom.xml b/oak-search-elastic/pom.xml
index 854e162057..4f951259c6 100644
--- a/oak-search-elastic/pom.xml
+++ b/oak-search-elastic/pom.xml
@@ -85,6 +85,18 @@
           </excludes>
         </configuration>
       </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-surefire-plugin</artifactId>
+        <version>3.2.5</version>
+        <configuration>
+          <environmentVariables>
+            <Authorization>Bearer test-token</Authorization>
+            <inferenceServiceUrl>http://localhost:8080/embeddings</inferenceServiceUrl>
+            <EMBEDDING_MODEL>embedding-test-model</EMBEDDING_MODEL>
+          </environmentVariables>
+        </configuration>
+      </plugin>
     </plugins>
   </build>
 
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/EnricherStatus.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/EnricherStatus.java
new file mode 100644
index 0000000000..b29a442014
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/EnricherStatus.java
@@ -0,0 +1,81 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.json.JsonUtils;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.apache.jackrabbit.oak.spi.state.NodeStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Configuration for inference payload
+ */
+public class EnricherStatus {
+    private static final Logger LOG = LoggerFactory.getLogger(EnricherStatus.class);
+
+    public static final EnricherStatus NOOP = new EnricherStatus();
+    private final Map<String, Object> enricherStatus;
+
+    public EnricherStatus() {
+        this.enricherStatus = Map.of();
+    }
+
+    public EnricherStatus(NodeStore nodeStore, String inferenceConfigPath) {
+        NodeState nodeState = nodeStore.getRoot();
+        for (String elem : PathUtils.elements(inferenceConfigPath)) {
+            nodeState = nodeState.getChildNode(elem);
+            if (!nodeState.exists()) {
+                this.enricherStatus = Map.of();
+                return;
+            }
+        }
+        for (String node : nodeState.getChildNodeNames()) {
+            if (node.equals(InferenceConstants.ENRICH_NODE)) {
+                nodeState = nodeState.getChildNode(node);
+                enricherStatus = JsonUtils.convertNodeStateToMap(nodeState, 0, false)
+                    .entrySet().stream()
+                    .filter(entry -> !entry.getKey().equals("jcr:primaryType"))
+                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+                return;
+            }
+        }
+        this.enricherStatus = Map.of();
+    }
+
+    /*
+     * Get the inference payload as a json string
+     *
+     * @param text
+     * @return
+     */
+    public Map<String, Object> getEnricherStatus() {
+        return enricherStatus;
+    }
+
+    @Override
+    public String toString() {
+        return enricherStatus.toString();
+    }
+
+} 
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfig.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfig.java
new file mode 100644
index 0000000000..34515360c8
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfig.java
@@ -0,0 +1,238 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.index.IndexName;
+import org.apache.jackrabbit.oak.query.QueryEngineSettings;
+import org.apache.jackrabbit.oak.spi.query.QueryLimits;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.apache.jackrabbit.oak.spi.state.NodeStore;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
+
+import static org.apache.jackrabbit.oak.plugins.index.search.util.ConfigUtil.getOptionalValue;
+
+/**
+ * Data model class representing the inference configuration stored under /oak:index/:inferenceConfig (default path)
+ */
+public class InferenceConfig {
+    private static final Logger LOG = LoggerFactory.getLogger(InferenceConfig.class.getName());
+    private static final ReadWriteLock lock = new ReentrantReadWriteLock(true);
+    private static final InferenceConfig INSTANCE = new InferenceConfig();
+    public static final String TYPE = "inferenceConfig";
+    /**
+     * Semantic search is enabled if this flag is true
+     */
+    private boolean enabled;
+    /**
+     * Map of index names to their respective inference configurations
+     */
+    private Map<String, InferenceIndexConfig> indexConfigs;
+    private EnricherStatus enricherStatus;
+    private NodeStore nodeStore;
+    private String inferenceConfigPath;
+    private String currentInferenceConfig;
+    private volatile String activeInferenceConfig;
+    private boolean isInferenceEnabled;
+
+    public boolean isInferenceEnabled() {
+        return isInferenceEnabled;
+    }
+
+    /**
+     * Loads configuration from the given NodeState
+     */
+
+    private InferenceConfig() {
+        lock.writeLock().lock();
+        try {
+            enabled = false;
+            indexConfigs = Map.of();
+            activeInferenceConfig = getNewInferenceConfigId();
+            currentInferenceConfig = activeInferenceConfig;
+            isInferenceEnabled = false;
+            enricherStatus = EnricherStatus.NOOP;
+        } finally {
+            lock.writeLock().unlock();
+        }
+    }
+
+    public static void reInitialize(NodeStore nodeStore, String inferenceConfigPath, boolean isInferenceEnabled) {
+        reInitialize(nodeStore, inferenceConfigPath, isInferenceEnabled, true);
+    }
+
+    public static void reInitialize(){
+        reInitialize(INSTANCE.nodeStore, INSTANCE.inferenceConfigPath, INSTANCE.isInferenceEnabled, true);
+    }
+
+    public static InferenceConfig getInstance() {
+        lock.readLock().lock();
+        try {
+            if (INSTANCE.activeInferenceConfig != null && !INSTANCE.activeInferenceConfig.equals(INSTANCE.currentInferenceConfig)) {
+                reInitialize(INSTANCE.nodeStore, INSTANCE.inferenceConfigPath, INSTANCE.isInferenceEnabled, false);
+            }
+            return INSTANCE;
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    private static void reInitialize(NodeStore nodeStore, String inferenceConfigPath, boolean isInferenceEnabled, boolean updateActiveInferenceConfig){
+        lock.writeLock().lock();
+        try {
+            if (updateActiveInferenceConfig) {
+                INSTANCE.activeInferenceConfig = getNewInferenceConfigId();
+            }
+            INSTANCE.currentInferenceConfig = INSTANCE.activeInferenceConfig;
+            INSTANCE.nodeStore = nodeStore;
+            INSTANCE.inferenceConfigPath = inferenceConfigPath;
+            INSTANCE.isInferenceEnabled = isInferenceEnabled;
+            INSTANCE.enricherStatus = new EnricherStatus(nodeStore, inferenceConfigPath);
+
+            if (!isValidInferenceConfig(nodeStore, inferenceConfigPath)) {
+                INSTANCE.enabled = false;
+                INSTANCE.indexConfigs = Map.of();
+            } else {
+                NodeState nodeState = nodeStore.getRoot();
+                for (String elem : PathUtils.elements(inferenceConfigPath)) {
+                    nodeState = nodeState.getChildNode(elem);
+                }
+                // Inference enabled or not.
+                INSTANCE.enabled = getOptionalValue(nodeState, InferenceConstants.ENABLED, false);
+                Map<String, InferenceIndexConfig> temp_indexConfigs = new HashMap<>();
+                // Read index configurations
+                for (String indexName : nodeState.getChildNodeNames()) {
+                    temp_indexConfigs.put(indexName, new InferenceIndexConfig(indexName, nodeState.getChildNode(indexName)));
+
+                }
+                INSTANCE.indexConfigs = Collections.unmodifiableMap(temp_indexConfigs);
+                //TODO Check if we we are also logging sensitive info.
+                LOG.info("Loaded inference configuration: " + INSTANCE);
+            }
+        } finally {
+            lock.writeLock().unlock();
+        }
+    }
+
+    public boolean isEnabled() {
+        lock.readLock().lock();
+        try {
+            return enabled;
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    public @NotNull InferenceIndexConfig getInferenceIndexConfig(String indexName) {
+        lock.readLock().lock();
+        try {
+            if (!isEnabled()) {
+                return InferenceIndexConfig.NOOP;
+            } else {
+                InferenceIndexConfig inferenceIndexConfig;
+                IndexName indexNameObject;
+                Function<String, InferenceIndexConfig> getInferenceIndexConfig = (iName) ->
+                        getIndexConfigs().getOrDefault(iName, InferenceIndexConfig.NOOP);
+                if (!InferenceIndexConfig.NOOP.equals(inferenceIndexConfig = getInferenceIndexConfig.apply(indexName))) {
+                    LOG.debug("InferenceIndexConfig for indexName: {} is: {}", indexName, inferenceIndexConfig);
+                } else if ((indexNameObject = IndexName.parse(indexName)) != null && indexNameObject.isLegal()
+                        && indexNameObject.getBaseName() != null
+                ) {
+                    LOG.debug("InferenceIndexConfig is using baseIndexName {} and is: {}", indexNameObject.getBaseName(), inferenceIndexConfig);
+                    inferenceIndexConfig = getInferenceIndexConfig.apply(indexNameObject.getBaseName());
+                }
+                return inferenceIndexConfig.isEnabled() ? inferenceIndexConfig : InferenceIndexConfig.NOOP;
+            }
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    public @NotNull InferenceModelConfig getInferenceModelConfig(String inferenceIndexName, String inferenceModelConfigName) {
+        lock.readLock().lock();
+        try {
+            if (inferenceModelConfigName == null){
+                return InferenceModelConfig.NOOP;
+            } else if (inferenceModelConfigName.isEmpty()) {
+                return getInferenceIndexConfig(inferenceIndexName).getDefaultEnabledModel();
+            } else {
+                return getInferenceIndexConfig(inferenceIndexName).getInferenceModelConfigs().getOrDefault(inferenceModelConfigName, InferenceModelConfig.NOOP);
+            }
+        } finally {
+            lock.readLock().unlock();
+        }
+
+    }
+
+    public Map<String, Object> getEnricherStatus(){
+        lock.readLock().lock();
+        try {
+            return INSTANCE.enricherStatus.getEnricherStatus();
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    private @NotNull Map<String, InferenceIndexConfig> getIndexConfigs() {
+        lock.readLock().lock();
+        try {
+            return isEnabled() ?
+                    Collections.unmodifiableMap(indexConfigs) : Map.of();
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    private static boolean isValidInferenceConfig(NodeStore nodeStore, String inferenceConfigPath) {
+
+        if (nodeStore == null) {
+            LOG.warn("InferenceConfig: NodeStore is null");
+            return false;
+        }
+        NodeState nodeState = nodeStore.getRoot();
+        if (inferenceConfigPath == null || inferenceConfigPath.isEmpty()) {
+            LOG.warn("InferenceConfig: Inference config path is null or empty");
+            return false;
+        }
+        for (String elem : PathUtils.elements(inferenceConfigPath)) {
+            nodeState = nodeState.getChildNode(elem);
+            if (!nodeState.exists()) {
+                LOG.warn("InferenceConfig: NodeState does not exist for path: " + inferenceConfigPath);
+                return false;
+            }
+        }
+        return getOptionalValue(nodeState, InferenceConstants.ENABLED, false);
+    }
+
+    private static String getNewInferenceConfigId() {
+        return UUID.randomUUID().toString();
+    }
+
+} 
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConstants.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConstants.java
new file mode 100644
index 0000000000..8a0cb1a0be
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConstants.java
@@ -0,0 +1,33 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+public interface InferenceConstants {
+    String ENABLED = "enabled";
+    String ENRICHER_CONFIG = "enricherConfig";
+    String TYPE = "type";
+    String DEFAULT_OAK_INDEX_INFERENCE_CONFIG_PATH = "/oak:index/:inferenceConfig";
+    String VECTOR_SPACES = ":vectorSpaces";
+    String VECTOR = "vector";
+    String ENRICH_NODE = ":enrich";
+    String DEFAULT_ENVIRONMENT_VARIABLE_PREFIX = "$";
+    String INFERENCE_ENVIRONMENT_VARIABLE_PREFIX = System.getProperty("org.apache.jackrabbit.oak.plugins.index.elastic.query.inference", DEFAULT_ENVIRONMENT_VARIABLE_PREFIX);
+    String DEFAULT_ENVIRONMENT_VARIABLE_VALUE = "";
+
+}
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceHeaderPayload.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceHeaderPayload.java
new file mode 100644
index 0000000000..53c387e20d
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceHeaderPayload.java
@@ -0,0 +1,70 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import org.apache.jackrabbit.oak.json.JsonUtils;
+import org.apache.jackrabbit.oak.plugins.index.elastic.util.EnvironmentVariableProcessorUtil;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Configuration for inference payload
+ */
+public class InferenceHeaderPayload {
+    private static final Logger LOG = LoggerFactory.getLogger(InferenceHeaderPayload.class);
+
+    public static final InferenceHeaderPayload NOOP = new InferenceHeaderPayload();
+    private final Map<String, String> inferenceHeaderPayloadMap;
+
+    public InferenceHeaderPayload() {
+        this.inferenceHeaderPayloadMap = Map.of();
+    }
+
+    public InferenceHeaderPayload(NodeState nodeState) {
+        inferenceHeaderPayloadMap = JsonUtils.convertNodeStateToMap(nodeState, 0, false)
+            .entrySet().stream().filter(entry -> entry.getValue() instanceof String)
+            .filter(entry -> !entry.getKey().equals("jcr:primaryType"))
+            .collect(HashMap::new, (map, entry) -> {
+                    String value = EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+                        InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, (String) entry.getValue(), InferenceConstants.DEFAULT_ENVIRONMENT_VARIABLE_VALUE);
+                    map.put(entry.getKey(), value);
+                },
+                HashMap::putAll);
+    }
+
+    /*
+     * Get the inference payload as a json string
+     *
+     * @param text
+     * @return
+     */
+    public Map<String, String> getInferenceHeaderPayload() {
+        return inferenceHeaderPayloadMap;
+    }
+
+    @Override
+    public String toString() {
+        return inferenceHeaderPayloadMap.toString();
+    }
+
+} 
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceIndexConfig.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceIndexConfig.java
new file mode 100644
index 0000000000..a7243655ed
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceIndexConfig.java
@@ -0,0 +1,125 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import org.apache.jackrabbit.oak.json.JsonUtils;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.jackrabbit.oak.plugins.index.search.util.ConfigUtil.getOptionalValue;
+
+/**
+ * Configuration class for Inference Index settings.
+ * Represents the configuration structure for inference-enabled indexes.
+ */
+public class InferenceIndexConfig {
+    private final static Logger LOG = LoggerFactory.getLogger(InferenceIndexConfig.class.getName());
+    public static final InferenceIndexConfig NOOP = new InferenceIndexConfig();
+    public static final String TYPE = "inferenceIndexConfig";
+    public static final String ENRICHER_CONFIG = "enricherConfig";
+    public static final String DISABLED_ENRICHER_CONFIG = "";
+
+    /**
+     * The enricher configuration as JSON string.
+     */
+    private final String enricherConfig;
+    /**
+     * Indicates whether the inference index is enabled or not.
+     */
+    private boolean isEnabled;
+    /**
+     * Map of inference model configurations keyed by their names.
+     */
+    private final Map<String, InferenceModelConfig> inferenceModelConfigs;
+
+    private InferenceIndexConfig() {
+        this.enricherConfig = DISABLED_ENRICHER_CONFIG;
+        this.isEnabled = false;
+        this.inferenceModelConfigs = Map.of();
+    }
+
+    public InferenceIndexConfig(String indexName, NodeState nodeState) {
+        boolean isValidType = getOptionalValue(nodeState, InferenceConstants.TYPE, "").equals(InferenceIndexConfig.TYPE);
+        this.isEnabled = getOptionalValue(nodeState, InferenceConstants.ENABLED, false);
+        String enricherString = getOptionalValue(nodeState, InferenceConstants.ENRICHER_CONFIG, "");
+        boolean isValidEnricherConfig = JsonUtils.isValidJson(enricherString, false);
+
+        if (isValidType && isEnabled && isValidEnricherConfig) {
+            this.enricherConfig = enricherString;
+            Map<String, InferenceModelConfig> tempInferenceModelConfigs = new HashMap<>();
+            // Iterate through child nodes to find inference model configs
+            for (String childName : nodeState.getChildNodeNames()) {
+                NodeState childNode = nodeState.getChildNode(childName);
+                tempInferenceModelConfigs.put(childName, new InferenceModelConfig(childName, childNode));
+            }
+            inferenceModelConfigs = Collections.unmodifiableMap(tempInferenceModelConfigs);
+        } else {
+            this.isEnabled = false;
+            this.enricherConfig = getOptionalValue(nodeState, InferenceConstants.ENRICHER_CONFIG, DISABLED_ENRICHER_CONFIG);
+            inferenceModelConfigs = Map.of();
+            LOG.warn("inference index config for indexName: {} is not valid. Node: {}",
+                    indexName, nodeState);
+        }
+    }
+
+    /**
+     * @return The enricher configuration JSON string
+     */
+    public String getEnricherConfig() {
+        return enricherConfig;
+    }
+
+    public boolean isEnabled() {
+        return isEnabled;
+    }
+
+    /**
+     * @return Map of inference model configurations keyed by their names
+     */
+    public Map<String, InferenceModelConfig> getInferenceModelConfigs() {
+        return inferenceModelConfigs;
+    }
+
+    /**
+     * Gets the enabled default inference model configuration if one exists
+     *
+     * @return The default InferenceModelConfig.java or null if none is marked as default
+     */
+    public InferenceModelConfig getDefaultEnabledModel() {
+        return inferenceModelConfigs.values().stream()
+                .filter(InferenceModelConfig::isDefault)
+                .filter(InferenceModelConfig::isEnabled)
+                .findFirst()
+                .orElse(InferenceModelConfig.NOOP);
+    }
+
+    @Override
+    public String toString() {
+        return TYPE + "{" +
+                ENRICHER_CONFIG + "='" + enricherConfig + '\'' +
+                ", " + InferenceModelConfig.TYPE + "=" + inferenceModelConfigs +
+                '}';
+    }
+
+}
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceModelConfig.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceModelConfig.java
new file mode 100644
index 0000000000..e308cc71c8
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceModelConfig.java
@@ -0,0 +1,176 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import org.apache.jackrabbit.oak.plugins.index.elastic.util.EnvironmentVariableProcessorUtil;
+import org.apache.jackrabbit.oak.spi.query.fulltext.InferenceQueryConfig;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.jackrabbit.oak.plugins.index.search.util.ConfigUtil.getOptionalValue;
+
+/**
+ * Configuration class for Inference Model settings.
+ * Currently only hybrid search is implemented
+ */
+public class InferenceModelConfig {
+    private static final Logger log = LoggerFactory.getLogger(InferenceModelConfig.class);
+    private static final String DEFAULT_ENVIRONMENT_VARIABLE_VALUE = "";
+
+    public static final InferenceModelConfig NOOP = new InferenceModelConfig();
+    public static final String MODEL = "model";
+    public static final String EMBEDDING_SERVICE_URL = "embeddingServiceUrl";
+    public static final String SIMILARITY_THRESHOLD = "similarityThreshold";
+    public static final String INFERENCE_PAYLOAD = "inferencePayload";
+    // InferenceQueryConfig also uses InferenceModelConfig.TYPE so referencing
+    // it from InferenceQueryConfig.
+    public static final String TYPE = InferenceQueryConfig.TYPE;
+    public static final String MIN_TERMS = "minTerms";
+    public static final String IS_DEFAULT = "isDefault";
+    public static final String ENABLED = "enabled";
+    public static final String HEADER = "header";
+    public static final String TIMEOUT = "timeout";
+    public static final String NUM_CANDIDATES = "numCandidates";
+    public static final String CACHE_SIZE = "cacheSize";
+    private static final double DEFAULT_SIMILARITY_THRESHOLD = 0.8;
+    private static final long DEFAULT_MIN_TERMS = 2;
+    private static final long DEFAULT_TIMEOUT_MILLIS = 5000L;
+    private static final int DEFAULT_NUM_CANDIDATES = 100;
+    private static final int DEFAULT_CACHE_SIZE = 100;
+
+
+    private final String model;
+    private final String embeddingServiceUrl;
+    private final boolean isDefault;
+    private final boolean enabled;
+    private final InferenceHeaderPayload header;
+    private final InferencePayload payload;
+    private final String inferenceModelConfigName;
+    private final double similarityThreshold;
+    private final long minTerms;
+    //The number of candidates to be returned by the query. Default is 100.
+    private final int numCandidates;
+    //The timeout for the query in milliseconds. Default is 5000.
+    private final long timeout;
+    private final int cacheSize;
+
+
+    public InferenceModelConfig() {
+        this.isDefault = false;
+        this.enabled = false;
+
+        this.model = "";
+        this.embeddingServiceUrl = "";
+        this.header = InferenceHeaderPayload.NOOP;
+        this.payload = InferencePayload.NOOP;
+        this.inferenceModelConfigName = "";
+        this.similarityThreshold = 0.0;
+        this.minTerms = 0L;
+        this.numCandidates = 0;
+        this.timeout = 0;
+        this.cacheSize = DEFAULT_CACHE_SIZE;
+    }
+
+    public InferenceModelConfig(String inferenceModelConfigName, NodeState nodeState) {
+        this.inferenceModelConfigName = inferenceModelConfigName;
+        this.enabled = getOptionalValue(nodeState, InferenceConstants.ENABLED, false);
+        boolean isValidType = getOptionalValue(nodeState, InferenceConstants.TYPE, "").equals(InferenceModelConfig.TYPE);
+        if (this.enabled && isValidType) {
+            this.header = new InferenceHeaderPayload(nodeState.getChildNode(HEADER));
+            this.payload = new InferencePayload(inferenceModelConfigName, nodeState.getChildNode(INFERENCE_PAYLOAD));
+        } else {
+            this.header = InferenceHeaderPayload.NOOP;
+            this.payload = InferencePayload.NOOP;
+        }
+        this.isDefault = getOptionalValue(nodeState, IS_DEFAULT, false);
+        this.model = getOptionalValue(nodeState, MODEL, "");
+        this.embeddingServiceUrl = EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+                InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, getOptionalValue(nodeState, EMBEDDING_SERVICE_URL, ""), DEFAULT_ENVIRONMENT_VARIABLE_VALUE);
+        this.similarityThreshold = getOptionalValue(nodeState, SIMILARITY_THRESHOLD, DEFAULT_SIMILARITY_THRESHOLD);
+        this.minTerms = getOptionalValue(nodeState, MIN_TERMS, DEFAULT_MIN_TERMS);
+        this.timeout = getOptionalValue(nodeState, TIMEOUT, DEFAULT_TIMEOUT_MILLIS);
+        this.numCandidates = getOptionalValue(nodeState, NUM_CANDIDATES, DEFAULT_NUM_CANDIDATES);
+        this.cacheSize = getOptionalValue(nodeState, CACHE_SIZE, 100);
+    }
+
+    @Override
+    public String toString() {
+        return TYPE + "{" +
+                MODEL + "='" + model + '\'' +
+                ", " + EMBEDDING_SERVICE_URL + "='" + embeddingServiceUrl + '\'' +
+                ", " + SIMILARITY_THRESHOLD + similarityThreshold +
+                ", " + MIN_TERMS + "=" + minTerms +
+                ", " + IS_DEFAULT + "=" + isDefault +
+                ", " + ENABLED + "=" + enabled +
+                ", " + HEADER + "=" + header +
+                ", " + INFERENCE_PAYLOAD + "=" + payload +
+                ", " + TIMEOUT + "=" + timeout +
+                ", " + NUM_CANDIDATES + "=" + numCandidates +
+                "}";
+    }
+
+    public String getInferenceModelConfigName() {
+        return inferenceModelConfigName;
+    }
+
+    public String getModel() {
+        return model;
+    }
+
+    public String getEmbeddingServiceUrl() {
+        return embeddingServiceUrl;
+    }
+
+    public boolean isDefault() {
+        return isDefault;
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public InferenceHeaderPayload getHeader() {
+        return header;
+    }
+
+    public InferencePayload getPayload() {
+        return payload;
+    }
+
+    public double getSimilarityThreshold() {
+        return similarityThreshold;
+    }
+
+    public long getMinTerms() {
+        return minTerms;
+    }
+
+    public int getNumCandidates() {
+        return numCandidates;
+    }
+
+    public long getTimeoutMillis() {
+        return timeout;
+    }
+
+    public int getCacheSize() {
+        return cacheSize;
+    }
+}
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferencePayload.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferencePayload.java
new file mode 100644
index 0000000000..ac230014a9
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferencePayload.java
@@ -0,0 +1,79 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.jackrabbit.oak.json.JsonUtils;
+import org.apache.jackrabbit.oak.plugins.index.elastic.util.EnvironmentVariableProcessorUtil;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Configuration for inference payload. We support open-ai api standard
+ */
+public class InferencePayload {
+    public static final InferencePayload NOOP = new InferencePayload();
+    private static final Logger LOG = LoggerFactory.getLogger(InferencePayload.class);
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+    private final Map<String, Object> inferencePayloadMap;
+    private final String inputTextKey = "input";
+
+    public InferencePayload() {
+        inferencePayloadMap = Map.of();
+    }
+
+    public InferencePayload(String inferenceModelName, NodeState nodeState) {
+        inferencePayloadMap = JsonUtils.convertNodeStateToMap(nodeState, 0, false);
+        inferencePayloadMap.remove("jcr:primaryType");
+        Map<String, String> swappedEnvVarsMap = inferencePayloadMap.entrySet().stream()
+            .filter(entry -> entry.getValue() instanceof String)
+            .collect(HashMap::new, (map, entry) -> {
+                    String value = EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+                        InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, (String) entry.getValue(), InferenceConstants.DEFAULT_ENVIRONMENT_VARIABLE_VALUE);
+                    map.put(entry.getKey(), value);
+                },
+                HashMap::putAll);
+        //replace current keys with swapped
+        inferencePayloadMap.putAll(swappedEnvVarsMap);
+    }
+    /*
+     * Get the inference payload as a json string
+     *
+     * @param text
+     * @return
+     */
+    public String getInferencePayload(String text) {
+
+        // This creates a shallow copy - only the map structure is cloned but values are still references
+        Map<String, Object> inferencePayloadMapCopy = new HashMap<>(inferencePayloadMap);
+        inferencePayloadMapCopy.put(inputTextKey, List.of(text));
+        try {
+            return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(inferencePayloadMapCopy);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+} 
\ No newline at end of file
diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/EnvironmentVariableProcessorUtil.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/EnvironmentVariableProcessorUtil.java
new file mode 100644
index 0000000000..a44d5ef6cc
--- /dev/null
+++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/EnvironmentVariableProcessorUtil.java
@@ -0,0 +1,54 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class EnvironmentVariableProcessorUtil {
+    private static final Logger LOG = LoggerFactory.getLogger(EnvironmentVariableProcessorUtil.class);
+
+    public static String getEnvironmentVariable(String variableName) {
+        String value = System.getenv(variableName);
+        if (value == null) {
+            throw new IllegalArgumentException("Environment variable " + variableName + " not found");
+        }
+        return value;
+    }
+
+    public static String processEnvironmentVariable(String envVariablePrefix, String variableNameIncludingPrefix, String defaultValue) {
+        String value = null;
+        if (variableNameIncludingPrefix.startsWith(envVariablePrefix)) {
+            String variableName = variableNameIncludingPrefix.substring(envVariablePrefix.length());
+            value = System.getenv(variableName);
+        } else {
+            value = variableNameIncludingPrefix;
+        }
+        if (value == null) {
+            LOG.warn("EnvironmentVariable {} used in inference header is not set", variableNameIncludingPrefix);
+            return defaultValue;
+        }
+        return value;
+    }
+
+    public static String processEnvironmentVariable(String envVariablePrefix, String variableNameIncludingPrefix) {
+        return processEnvironmentVariable(envVariablePrefix, variableNameIncludingPrefix, null);
+    }
+
+}
diff --git a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfigTest.java b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfigTest.java
new file mode 100644
index 0000000000..8ae4254996
--- /dev/null
+++ b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/InferenceConfigTest.java
@@ -0,0 +1,786 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import joptsimple.internal.Strings;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.plugins.index.elastic.util.EnvironmentVariableProcessorUtil;
+import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState;
+import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeBuilder;
+import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.EmptyHook;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeStore;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class InferenceConfigTest {
+
+    private static final Logger LOG = LoggerFactory.getLogger(InferenceConfigTest.class);
+    String ENRICH_STATUS_PENDING = "PENDING";
+    private static final String DEFAULT_CONFIG_PATH = InferenceConstants.DEFAULT_OAK_INDEX_INFERENCE_CONFIG_PATH;
+    private static final String ENRICHER_CONFIG = "{\"enricher\":{\"config\":{\"vectorSpaces\":{\"semantic\":{\"pipeline\":{\"steps\":[{\"inputFields\":{\"description\":\"STRING\",\"title\":\"STRING\"},\"chunkingConfig\":{\"enabled\":true},\"name\":\"sentence-embeddings\",\"model\":\"text-embedding-ada-002\",\"optional\":true,\"type\":\"embeddings\"}]},\"default\":false}},\"version\":\"0.0.1\"}}}";
+
+    private NodeBuilder rootBuilder;
+    private NodeStore nodeStore;
+
+    private final String AUTH_ENV_VARIABLE = "$Authorization";
+    private final String INFERENCE_SERVICE_URL_ENV_VARIABLE = "$inferenceServiceUrl";
+    private final String INFERENCE_PAYLOAD_MODEL = "$EMBEDDING_MODEL";
+
+    private boolean isAuthEnvVarDefined;
+    private boolean isInferenceUrlEnvVarDefined;
+    private boolean isInferencePayloadModelDefined;
+
+    @Before
+    public void setup() {
+        // Initialize memory node store
+        rootBuilder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        nodeStore = new MemoryNodeStore(rootBuilder.getNodeState());
+
+        isAuthEnvVarDefined = !EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+            InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, AUTH_ENV_VARIABLE, "").equals(Strings.EMPTY);
+        isInferenceUrlEnvVarDefined = !EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+            InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, INFERENCE_SERVICE_URL_ENV_VARIABLE, "").equals(Strings.EMPTY);
+        isInferencePayloadModelDefined = !EnvironmentVariableProcessorUtil.processEnvironmentVariable(
+            InferenceConstants.INFERENCE_ENVIRONMENT_VARIABLE_PREFIX, INFERENCE_PAYLOAD_MODEL, "").equals(Strings.EMPTY);
+    }
+
+    @After
+    public void tearDown() {
+        rootBuilder = null;
+        nodeStore = null;
+    }
+
+    /**
+     * Test 1: Basic test - Disabled InferenceConfig
+     * Verifies that when inference config is created but disabled, the InferenceConfig object reflects this state
+     */
+    @Test
+    public void testDisabledInferenceConfig() throws CommitFailedException {
+        // Create disabled inference config node structure
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, false);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object using the nodeStore
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertFalse("InferenceConfig should be disabled", inferenceConfig.isEnabled());
+    }
+
+    /**
+     * Test 2: Enabled InferenceConfig but no index configs
+     * Verifies that when an empty inference config is enabled, the InferenceConfig object reflects this state
+     */
+    @Test
+    public void testEnabledEmptyInferenceConfig() throws CommitFailedException {
+        // Create enabled inference config node structure
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object using the nodeStore
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+    }
+
+    /**
+     * Test 3: Basic InferenceIndexConfig creation
+     * Tests the creation of a simple InferenceIndexConfig within InferenceConfig
+     */
+    @Test
+    public void testBasicInferenceIndexConfig() throws CommitFailedException {
+        // Create enabled inference config with one index config
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+//        assertEquals("Should have one index config", 1, inferenceConfig.getIndexConfigs().size());
+        assertTrue("Should contain the index config", inferenceConfig.getInferenceIndexConfig(indexName).isEnabled());
+
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertTrue("Index config should be enabled", indexConfig.isEnabled());
+        assertEquals("Enricher config should match", ENRICHER_CONFIG, indexConfig.getEnricherConfig());
+        assertTrue("Model configs should be empty", indexConfig.getInferenceModelConfigs().isEmpty());
+    }
+
+    /**
+     * Test 4: Disabled InferenceIndexConfig
+     * Tests that a disabled InferenceIndexConfig is properly handled
+     */
+    @Test
+    public void testDisabledInferenceIndexConfig() throws CommitFailedException, JsonProcessingException {
+        // Create enabled inference config with one disabled index config
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add disabled index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, false);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertFalse("Index config should be disabled", indexConfig.isEnabled());
+        // When indexConfig is disabled, we should get the NOOP instance
+        assertEquals("Should get NOOP instance", InferenceIndexConfig.NOOP, indexConfig);
+    }
+
+    /**
+     * Test 5: Invalid InferenceIndexConfig (missing type)
+     * Tests that an invalid InferenceIndexConfig (missing type) is properly handled
+     */
+    @Test
+    public void testInvalidInferenceIndexConfig() throws CommitFailedException {
+        // Create enabled inference config with one invalid index config (missing type)
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add invalid index config (missing type)
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        // Intentionally not setting the TYPE property
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertFalse("Invalid index config should be treated as disabled", indexConfig.isEnabled());
+        assertEquals("Should get NOOP instance", InferenceIndexConfig.NOOP, indexConfig);
+    }
+
+    /**
+     * Test 6: Basic InferenceModelConfig
+     * Tests the creation of an InferenceModelConfig within an InferenceIndexConfig
+     */
+    @Test
+    public void testBasicInferenceModelConfig() throws CommitFailedException {
+        // Create enabled inference config with an index config containing a model config
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Add model config
+        String modelName = "testModel";
+        NodeBuilder modelConfigBuilder = indexConfigBuilder.child(modelName);
+        modelConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        modelConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        modelConfigBuilder.setProperty(InferenceModelConfig.IS_DEFAULT, true);
+        modelConfigBuilder.setProperty(InferenceModelConfig.MODEL, "test-embedding-model");
+        modelConfigBuilder.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, "http://localhost:8080/embeddings");
+        modelConfigBuilder.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.8);
+        modelConfigBuilder.setProperty(InferenceModelConfig.MIN_TERMS, 3L);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertTrue("Index config should be enabled", indexConfig.isEnabled());
+        assertEquals("Should have one model config", 1, indexConfig.getInferenceModelConfigs().size());
+
+        InferenceModelConfig modelConfig = indexConfig.getInferenceModelConfigs().get(modelName);
+        assertNotNull("Model config should exist", modelConfig);
+        assertTrue("Model config should be enabled", modelConfig.isEnabled());
+        assertTrue("Model config should be default", modelConfig.isDefault());
+        assertEquals("Model name should match", "test-embedding-model", modelConfig.getModel());
+        assertEquals("Embedding service URL should match", "http://localhost:8080/embeddings", modelConfig.getEmbeddingServiceUrl());
+        assertEquals("Similarity threshold should match", 0.8, modelConfig.getSimilarityThreshold(), 0.001);
+        assertEquals("Min terms should match", 3L, modelConfig.getMinTerms());
+    }
+
+    /**
+     * Test 7: Multiple InferenceModelConfigs with one default
+     * Tests multiple InferenceModelConfigs within an InferenceIndexConfig, with one marked as default
+     */
+    @Test
+    public void testMultipleInferenceModelConfigs() throws CommitFailedException {
+        // Create enabled inference config with an index config containing multiple model configs
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Add default model config
+        String defaultModelName = "defaultModel";
+        NodeBuilder defaultModelConfigBuilder = indexConfigBuilder.child(defaultModelName);
+        defaultModelConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        defaultModelConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        defaultModelConfigBuilder.setProperty(InferenceModelConfig.IS_DEFAULT, true);
+        defaultModelConfigBuilder.setProperty(InferenceModelConfig.MODEL, "default-embedding-model");
+        defaultModelConfigBuilder.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, "http://localhost:8080/default-embeddings");
+        defaultModelConfigBuilder.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.8);
+        defaultModelConfigBuilder.setProperty(InferenceModelConfig.MIN_TERMS, 3L);
+
+        // Add non-default model config
+        String nonDefaultModelName = "nonDefaultModel";
+        NodeBuilder nonDefaultModelConfigBuilder = indexConfigBuilder.child(nonDefaultModelName);
+        nonDefaultModelConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        nonDefaultModelConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        nonDefaultModelConfigBuilder.setProperty(InferenceModelConfig.IS_DEFAULT, false);
+        nonDefaultModelConfigBuilder.setProperty(InferenceModelConfig.MODEL, "non-default-embedding-model");
+        nonDefaultModelConfigBuilder.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, "http://localhost:8080/non-default-embeddings");
+        nonDefaultModelConfigBuilder.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.7);
+        nonDefaultModelConfigBuilder.setProperty(InferenceModelConfig.MIN_TERMS, 2L);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertTrue("Index config should be enabled", indexConfig.isEnabled());
+        assertEquals("Should have two model configs", 2, indexConfig.getInferenceModelConfigs().size());
+
+        // Verify default model config
+        InferenceModelConfig defaultModel = indexConfig.getDefaultEnabledModel();
+        assertNotNull("Default model should exist", defaultModel);
+        assertEquals("Default model name should match", defaultModelName, defaultModel.getInferenceModelConfigName());
+        assertTrue("Default model should be marked as default", defaultModel.isDefault());
+
+        // Verify non-default model config
+        InferenceModelConfig nonDefaultModel = indexConfig.getInferenceModelConfigs().get(nonDefaultModelName);
+        assertNotNull("Non-default model should exist", nonDefaultModel);
+        assertFalse("Non-default model should not be marked as default", nonDefaultModel.isDefault());
+    }
+
+    /**
+     * Test 8: Complete configuration with multiple indexes and models
+     * Tests a complex configuration with multiple indexes and models
+     */
+    @Test
+    public void testCompleteConfiguration() throws CommitFailedException {
+
+        assertTrue(isAuthEnvVarDefined && isInferenceUrlEnvVarDefined && isInferencePayloadModelDefined);
+        // Create enabled inference config with multiple index configs
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // First index config
+        String indexName1 = "testIndex1";
+        NodeBuilder indexConfigBuilder1 = inferenceConfigBuilder.child(indexName1);
+        indexConfigBuilder1.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder1.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder1.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Add model config to first index
+        String modelName1 = "testModel1";
+        NodeBuilder modelConfigBuilder1 = indexConfigBuilder1.child(modelName1);
+        modelConfigBuilder1.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        modelConfigBuilder1.setProperty(InferenceConstants.ENABLED, true);
+        modelConfigBuilder1.setProperty(InferenceModelConfig.IS_DEFAULT, true);
+        modelConfigBuilder1.setProperty(InferenceModelConfig.MODEL, "model1");
+        modelConfigBuilder1.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, INFERENCE_SERVICE_URL_ENV_VARIABLE);
+        modelConfigBuilder1.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.8);
+        modelConfigBuilder1.setProperty(InferenceModelConfig.MIN_TERMS, 3L);
+
+        // Add header and payload for model1
+        NodeBuilder headerBuilder1 = modelConfigBuilder1.child(InferenceModelConfig.HEADER);
+        headerBuilder1.setProperty("Content-Type", "application/json");
+        headerBuilder1.setProperty("Authorization", AUTH_ENV_VARIABLE);
+        headerBuilder1.setProperty("jcr:primaryType", "nt:unstructured");
+
+        NodeBuilder payloadBuilder1 = modelConfigBuilder1.child(InferenceModelConfig.INFERENCE_PAYLOAD);
+        payloadBuilder1.setProperty("model", INFERENCE_PAYLOAD_MODEL);
+        payloadBuilder1.setProperty("jcr:primaryType", "nt:unstructured");
+
+        // Second index config
+        String indexName2 = "testIndex2";
+        NodeBuilder indexConfigBuilder2 = inferenceConfigBuilder.child(indexName2);
+        indexConfigBuilder2.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder2.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder2.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Add model config to second index
+        String modelName2 = "testModel2";
+        NodeBuilder modelConfigBuilder2 = indexConfigBuilder2.child(modelName2);
+        modelConfigBuilder2.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        modelConfigBuilder2.setProperty(InferenceConstants.ENABLED, true);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.IS_DEFAULT, true);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.MODEL, "model2");
+        modelConfigBuilder2.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, INFERENCE_SERVICE_URL_ENV_VARIABLE);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.7);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.MIN_TERMS, 2L);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.TIMEOUT, 10000L);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.NUM_CANDIDATES, 200);
+        modelConfigBuilder2.setProperty(InferenceModelConfig.CACHE_SIZE, 200);
+
+        // Add header and payload for model2
+        NodeBuilder headerBuilder2 = modelConfigBuilder2.child(InferenceModelConfig.HEADER);
+        headerBuilder2.setProperty("Content-Type", "application/json");
+        headerBuilder2.setProperty("Authorization", AUTH_ENV_VARIABLE);
+        headerBuilder2.setProperty("jcr:primaryType", "nt:unstructured");
+
+        NodeBuilder payloadBuilder2 = modelConfigBuilder2.child(InferenceModelConfig.INFERENCE_PAYLOAD);
+        payloadBuilder2.setProperty("model", "text-embedding-3-large");
+        payloadBuilder2.setProperty("dimensions", 1024);
+        payloadBuilder2.setProperty("jcr:primaryType", "nt:unstructured");
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        // Test first index config
+        InferenceIndexConfig indexConfig1 = inferenceConfig.getInferenceIndexConfig(indexName1);
+        assertTrue("Index config 1 should be enabled", indexConfig1.isEnabled());
+        assertEquals("Index config 1 should have one model config", 1, indexConfig1.getInferenceModelConfigs().size());
+
+        // Test model config in first index
+        InferenceModelConfig modelConfig1 = indexConfig1.getInferenceModelConfigs().get(modelName1);
+        assertNotNull("Model config 1 should exist", modelConfig1);
+        assertTrue("Model config 1 should be enabled", modelConfig1.isEnabled());
+        assertTrue("Model config 1 should be default", modelConfig1.isDefault());
+        assertEquals("Model 1 name should match", "model1", modelConfig1.getModel());
+        assertEquals("Model 1 similarity threshold should match", 0.8, modelConfig1.getSimilarityThreshold(), 0.001);
+        assertFalse("Payload should not have jcr:primaryType property",
+            modelConfig1.getPayload().getInferencePayload("input text").contains("jcr:primaryType"));
+        assertFalse("Header Payload should not have jcr:primaryType property", modelConfig1.getHeader().getInferenceHeaderPayload().containsKey("jcr:primaryType"));
+        assertFalse("Model 1 payload model should not contain " + INFERENCE_PAYLOAD_MODEL, modelConfig1.getPayload().getInferencePayload("input-text").contains(INFERENCE_PAYLOAD_MODEL));
+
+        // Test second index config
+        InferenceIndexConfig indexConfig2 = inferenceConfig.getInferenceIndexConfig(indexName2);
+        assertTrue("Index config 2 should be enabled", indexConfig2.isEnabled());
+        assertEquals("Index config 2 should have one model config", 1, indexConfig2.getInferenceModelConfigs().size());
+
+        // Test model config in second index
+        InferenceModelConfig modelConfig2 = indexConfig2.getInferenceModelConfigs().get(modelName2);
+        assertNotNull("Model config 2 should exist", modelConfig2);
+        assertTrue("Model config 2 should be enabled", modelConfig2.isEnabled());
+        assertTrue("Model config 2 should be default", modelConfig2.isDefault());
+        assertEquals("Model 2 name should match", "model2", modelConfig2.getModel());
+        assertEquals("Model 2 similarity threshold should match", 0.7, modelConfig2.getSimilarityThreshold(), 0.001);
+        assertEquals("Model 2 timeout should match", 10000L, modelConfig2.getTimeoutMillis());
+        assertEquals("Model 2 num candidates should match", 200, modelConfig2.getNumCandidates());
+        assertEquals("Model 2 cache size should match", 200, modelConfig2.getCacheSize());
+        assertNotEquals("Model 2 embedding service URL should not match", INFERENCE_SERVICE_URL_ENV_VARIABLE, modelConfig2.getEmbeddingServiceUrl());
+        assertNotEquals("Model 2 embedding service URL should not match empty string", "", modelConfig2.getEmbeddingServiceUrl());
+        // this is picked from pom.xml during test
+        assertEquals("Model 2 embedding service URL should match", "http://localhost:8080/embeddings", modelConfig2.getEmbeddingServiceUrl());
+        assertFalse("Payload should not have jcr:primaryType property",
+            modelConfig2.getPayload().getInferencePayload("input text").contains("jcr:primaryType"));
+        assertFalse("Header Payload should not have jcr:primaryType property", modelConfig2.getHeader().getInferenceHeaderPayload().containsKey("jcr:primaryType"));
+
+    }
+
+    /**
+     * Test 9: Test refreshConfig method
+     * Tests that the refreshConfig method properly updates the configuration
+     */
+    @Test
+    public void testRefreshConfig() throws CommitFailedException {
+        // Create initial enabled inference config with one index
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify initial state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        // Modify the configuration in the nodeStore
+        NodeBuilder updatedRootBuilder = nodeStore.getRoot().builder();
+        NodeBuilder updatedConfigBuilder = createNodePath(updatedRootBuilder, DEFAULT_CONFIG_PATH);
+
+        // Add a new index config
+        String newIndexName = "newIndex";
+        NodeBuilder newIndexConfigBuilder = updatedConfigBuilder.child(newIndexName);
+        newIndexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        newIndexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        newIndexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Commit the changes
+        nodeStore.merge(updatedRootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+        assertFalse("Should not have new index config", inferenceConfig.getInferenceIndexConfig(newIndexName).isEnabled());
+
+        // Refresh the InferenceConfig
+        InferenceConfig.reInitialize();
+        // Verify updated state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+        assertTrue("Should contain the new index config", inferenceConfig.getInferenceIndexConfig(newIndexName).isEnabled());
+    }
+
+    /**
+     * Test 10: Test EnricherStatus
+     * Tests that the EnricherStatus is properly loaded from the inference config
+     */
+    @Test
+    public void testEnricherStatus() throws CommitFailedException {
+        // Create enabled inference config with enrich status node
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add the enricher status node
+        NodeBuilder enrichBuilder = inferenceConfigBuilder.child(InferenceConstants.ENRICH_NODE);
+        enrichBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+        enrichBuilder.setProperty("status", ENRICH_STATUS_PENDING);
+        enrichBuilder.setProperty("timestamp", "2023-01-01T12:00:00Z");
+        enrichBuilder.setProperty("count", 100);
+
+        // Add some nested data
+        NodeBuilder statsBuilder = enrichBuilder.child("stats");
+        statsBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+        statsBuilder.setProperty("totalDocs", 1000);
+        statsBuilder.setProperty("processedDocs", 950);
+        statsBuilder.setProperty("skippedDocs", 50);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the enricher status
+        Map<String, Object> enricherStatus = inferenceConfig.getEnricherStatus();
+
+        assertNotNull("Enricher status should not be null", enricherStatus);
+        assertFalse("Enricher status should not be empty", enricherStatus.isEmpty());
+
+        // Check top-level properties
+        assertEquals("Status should match", ENRICH_STATUS_PENDING, enricherStatus.get("status"));
+        assertEquals("Timestamp should match", "2023-01-01T12:00:00Z", enricherStatus.get("timestamp"));
+        assertEquals("Count should match", 100L, enricherStatus.get("count"));
+
+        // Check nested properties
+        @SuppressWarnings("unchecked")
+        Map<String, Object> stats = (Map<String, Object>) enricherStatus.get("stats");
+        assertNull("Stats should not exist", stats);
+        // Verify that jcr:primaryType is not included
+        assertFalse("jcr:primaryType should not be included", enricherStatus.containsKey("jcr:primaryType"));
+    }
+
+    /**
+     * Test 11: Test Empty EnricherStatus
+     * Tests the behavior when no enricher status is present
+     */
+    @Test
+    public void testEmptyEnricherStatus() throws CommitFailedException {
+        // Create enabled inference config without enrich status node
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the enricher status
+        Map<String, Object> enricherStatus = inferenceConfig.getEnricherStatus();
+
+        assertNotNull("Enricher status should not be null", enricherStatus);
+        assertTrue("Enricher status should be empty", enricherStatus.isEmpty());
+    }
+
+    /**
+     * Test 12: Test EnricherStatus when inference config doesn't exist
+     * Tests that an empty enricher status is returned when the inference config doesn't exist
+     */
+    @Test
+    public void testEnricherStatusWithNoInferenceConfig() {
+        // Create a different NodeStore without any inference config
+        NodeBuilder emptyRootBuilder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        NodeStore emptyNodeStore = new MemoryNodeStore(emptyRootBuilder.getNodeState());
+
+        // Create EnricherStatus directly (not through InferenceConfig)
+        EnricherStatus enricherStatus = new EnricherStatus(emptyNodeStore, DEFAULT_CONFIG_PATH);
+
+        // Verify the enricher status
+        Map<String, Object> status = enricherStatus.getEnricherStatus();
+
+        assertNotNull("Enricher status should not be null", status);
+        assertTrue("Enricher status should be empty", status.isEmpty());
+    }
+
+    /**
+     * Test 13: Test EnricherStatus Refresh
+     * Tests that the EnricherStatus is properly refreshed when the inference config is updated
+     */
+    @Test
+    public void testEnricherStatusRefresh() throws CommitFailedException {
+        // Create enabled inference config with initial enrich status node
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add the initial enricher status node with PENDING status
+        NodeBuilder enrichBuilder = inferenceConfigBuilder.child(InferenceConstants.ENRICH_NODE);
+        enrichBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+        enrichBuilder.setProperty("status", ENRICH_STATUS_PENDING);
+        enrichBuilder.setProperty("timestamp", "2023-01-01T10:00:00Z");
+        enrichBuilder.setProperty("count", 0);
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the initial enricher status
+        Map<String, Object> initialStatus = inferenceConfig.getEnricherStatus();
+        assertEquals("Initial status should be PENDING", ENRICH_STATUS_PENDING, initialStatus.get("status"));
+        assertEquals("Initial timestamp should match", "2023-01-01T10:00:00Z", initialStatus.get("timestamp"));
+        assertEquals("Initial count should be 0", 0L, initialStatus.get("count"));
+
+        // Now update the enricher status to COMPLETED
+        NodeBuilder updatedRootBuilder = nodeStore.getRoot().builder();
+        NodeBuilder updatedConfigBuilder = createNodePath(updatedRootBuilder, DEFAULT_CONFIG_PATH);
+        NodeBuilder updatedEnrichBuilder = updatedConfigBuilder.child(InferenceConstants.ENRICH_NODE);
+        updatedEnrichBuilder.setProperty("status", ENRICH_STATUS_PENDING);
+        updatedEnrichBuilder.setProperty("timestamp", "2023-01-01T12:00:00Z");
+        updatedEnrichBuilder.setProperty("count", 100L);
+
+        // Commit the updated changes
+        nodeStore.merge(updatedRootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Refresh the InferenceConfig
+        InferenceConfig.reInitialize();
+
+        // Get the updated enricher status
+        Map<String, Object> updatedStatus = inferenceConfig.getEnricherStatus();
+
+        // Verify the updated enricher status
+        assertEquals("Updated status should be COMPLETED", ENRICH_STATUS_PENDING, updatedStatus.get("status"));
+        assertEquals("Updated timestamp should match", "2023-01-01T12:00:00Z", updatedStatus.get("timestamp"));
+        assertEquals("Updated count should be 100", 100L, updatedStatus.get("count"));
+    }
+
+    /**
+     * Test 14: Test Complete Integration with EnricherStatus
+     * Tests the complete integration of InferenceConfig, InferenceIndexConfig, InferenceModelConfig, and EnricherStatus
+     */
+    @Test
+    public void testCompleteIntegrationWithEnricherStatus() throws CommitFailedException {
+        assertTrue(isAuthEnvVarDefined && isInferenceUrlEnvVarDefined && isInferencePayloadModelDefined);
+
+        // Create enabled inference config with complete integration
+        NodeBuilder inferenceConfigBuilder = createNodePath(rootBuilder, DEFAULT_CONFIG_PATH);
+        inferenceConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceConfig.TYPE);
+        inferenceConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+
+        // Add enricher status node
+        NodeBuilder enrichBuilder = inferenceConfigBuilder.child(InferenceConstants.ENRICH_NODE);
+        enrichBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+        enrichBuilder.setProperty("status", ENRICH_STATUS_PENDING);
+        enrichBuilder.setProperty("timestamp", "2023-01-01T12:00:00Z");
+        enrichBuilder.setProperty("count", 100);
+
+        // Add stats to enricher status
+        NodeBuilder statsBuilder = enrichBuilder.child("stats");
+        statsBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+        statsBuilder.setProperty("totalDocs", 1000);
+        statsBuilder.setProperty("processedDocs", 950);
+        statsBuilder.setProperty("skippedDocs", 50);
+
+        // Add index config
+        String indexName = "testIndex";
+        NodeBuilder indexConfigBuilder = inferenceConfigBuilder.child(indexName);
+        indexConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceIndexConfig.TYPE);
+        indexConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        indexConfigBuilder.setProperty(InferenceConstants.ENRICHER_CONFIG, ENRICHER_CONFIG);
+
+        // Add model config
+        String modelName = "testModel";
+        NodeBuilder modelConfigBuilder = indexConfigBuilder.child(modelName);
+        modelConfigBuilder.setProperty(InferenceConstants.TYPE, InferenceModelConfig.TYPE);
+        modelConfigBuilder.setProperty(InferenceConstants.ENABLED, true);
+        modelConfigBuilder.setProperty(InferenceModelConfig.IS_DEFAULT, true);
+        modelConfigBuilder.setProperty(InferenceModelConfig.MODEL, "test-embedding-model");
+        modelConfigBuilder.setProperty(InferenceModelConfig.EMBEDDING_SERVICE_URL, INFERENCE_SERVICE_URL_ENV_VARIABLE);
+        modelConfigBuilder.setProperty(InferenceModelConfig.SIMILARITY_THRESHOLD, 0.8);
+        modelConfigBuilder.setProperty(InferenceModelConfig.MIN_TERMS, 3L);
+
+        // Add header and payload for model
+        NodeBuilder headerBuilder = modelConfigBuilder.child(InferenceModelConfig.HEADER);
+        headerBuilder.setProperty("Content-Type", "application/json");
+        headerBuilder.setProperty("Authorization", AUTH_ENV_VARIABLE);
+        headerBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+
+        NodeBuilder payloadBuilder = modelConfigBuilder.child(InferenceModelConfig.INFERENCE_PAYLOAD);
+        payloadBuilder.setProperty("model", INFERENCE_PAYLOAD_MODEL);
+        payloadBuilder.setProperty("jcr:primaryType", "nt:unstructured");
+
+        // Commit the changes
+        nodeStore.merge(rootBuilder, EmptyHook.INSTANCE, CommitInfo.EMPTY);
+
+        // Create InferenceConfig object
+        InferenceConfig.reInitialize(nodeStore, DEFAULT_CONFIG_PATH, true);
+        InferenceConfig inferenceConfig = InferenceConfig.getInstance();
+
+        // Verify the inference config state
+        assertTrue("InferenceConfig should be enabled", inferenceConfig.isEnabled());
+
+        // Verify the enricher status
+        Map<String, Object> enricherStatus = inferenceConfig.getEnricherStatus();
+        assertNotNull("Enricher status should not be null", enricherStatus);
+        assertFalse("Enricher status should not be empty", enricherStatus.isEmpty());
+        assertEquals("Status should match", ENRICH_STATUS_PENDING, enricherStatus.get("status"));
+
+        // Verify nested stats
+        @SuppressWarnings("unchecked")
+        Map<String, Object> stats = (Map<String, Object>) enricherStatus.get("stats");
+        assertNull("Stats should not exist", stats);
+        // Verify that jcr:primaryType is not included
+        assertFalse("jcr:primaryType should not be included", enricherStatus.containsKey("jcr:primaryType"));
+
+        // Verify the index config
+        InferenceIndexConfig indexConfig = inferenceConfig.getInferenceIndexConfig(indexName);
+        assertTrue("Index config should be enabled", indexConfig.isEnabled());
+        assertEquals("Enricher config should match", ENRICHER_CONFIG, indexConfig.getEnricherConfig());
+
+        // Verify the model config
+        InferenceModelConfig modelConfig = indexConfig.getInferenceModelConfigs().get(modelName);
+        assertNotNull("Model config should exist", modelConfig);
+        assertTrue("Model config should be enabled", modelConfig.isEnabled());
+        assertTrue("Model config should be default", modelConfig.isDefault());
+        assertEquals("Model name should match", "test-embedding-model", modelConfig.getModel());
+
+        // Verify model config details
+        assertNotEquals("Model's embedding service URL should not match the env variable",
+            INFERENCE_SERVICE_URL_ENV_VARIABLE, modelConfig.getEmbeddingServiceUrl());
+        assertFalse("Payload should not have jcr:primaryType property",
+            modelConfig.getPayload().getInferencePayload("test").contains("jcr:primaryType"));
+        assertFalse("Header should not have jcr:primaryType property",
+            modelConfig.getHeader().getInferenceHeaderPayload().containsKey("jcr:primaryType"));
+    }
+
+    /**
+     * Utility method to create a path of nodes
+     */
+    private NodeBuilder createNodePath(NodeBuilder rootBuilder, String path) {
+        NodeBuilder builder = rootBuilder;
+        for (String elem : PathUtils.elements(path)) {
+            builder = builder.child(elem);
+        }
+        return builder;
+    }
+} 
\ No newline at end of file
diff --git a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/VectorDocument.java b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/VectorDocument.java
new file mode 100644
index 0000000000..2021bda439
--- /dev/null
+++ b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/inference/VectorDocument.java
@@ -0,0 +1,56 @@
+/*
+ * 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.jackrabbit.oak.plugins.index.elastic.query.inference;
+
+import java.util.List;
+import java.util.Map;
+
+public class VectorDocument {
+
+    public static final String ID = "id";
+    public static final String VECTOR = "vector";
+    public static final String METADATA = "metadata";
+
+    public final String id;
+
+    public final List<Float> vector;
+
+    public final Map<String, Object> metadata;
+
+    public VectorDocument() {
+        this.id = null;
+        this.vector = null;
+        this.metadata = null;
+    }
+
+    public VectorDocument(String id, List<Float> vector, Map<String, Object> metadata) {
+        this.id = id;
+        this.vector = vector;
+        this.metadata = metadata;
+    }
+
+    @Override
+    public String toString() {
+        return "VectorDocument{" +
+                "id='" + id + '\'' +
+                ", vector=" + vector +
+                ", metadata=" + metadata +
+                '}';
+    }
+}
\ No newline at end of file
diff --git a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/BackwardCompatibleMountCompositeIT.java b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/BackwardCompatibleMountCompositeIT.java
index 9e960be120..a3d0ea0927 100644
--- a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/BackwardCompatibleMountCompositeIT.java
+++ b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/BackwardCompatibleMountCompositeIT.java
@@ -36,6 +36,7 @@ import org.ops4j.pax.exam.spi.reactors.PerMethod;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
 import static org.ops4j.pax.exam.CoreOptions.options;
 
 /**
diff --git a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
index e838ecb1f1..887326c669 100644
--- a/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
+++ b/oak-store-composite/src/test/java/org/apache/jackrabbit/oak/composite/it/CompositeTestSupport.java
@@ -24,7 +24,6 @@ import java.nio.file.Paths;
 
 import org.apache.sling.testing.paxexam.SlingOptions;
 import org.apache.sling.testing.paxexam.TestSupport;
-import org.junit.BeforeClass;
 import org.ops4j.pax.exam.Option;
 import org.ops4j.pax.exam.options.ModifiableCompositeOption;
 
@@ -97,7 +96,10 @@ public abstract class CompositeTestSupport extends TestSupport {
             mavenBundle().groupId("commons-codec").artifactId("commons-codec").versionAsInProject(),
             mavenBundle().groupId("commons-io").artifactId("commons-io").versionAsInProject(),
             mavenBundle().groupId("org.apache.commons").artifactId("commons-collections4").versionAsInProject(),
-            mavenBundle().groupId("org.apache.commons").artifactId("commons-lang3").versionAsInProject()
+            mavenBundle().groupId("org.apache.commons").artifactId("commons-lang3").versionAsInProject(),
+            mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-core").versionAsInProject(),
+            mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-annotations").versionAsInProject(),
+            mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-databind").versionAsInProject()
         );
     }
 
diff --git a/oak-store-spi/pom.xml b/oak-store-spi/pom.xml
index 5245a5b121..ba34c07de9 100644
--- a/oak-store-spi/pom.xml
+++ b/oak-store-spi/pom.xml
@@ -117,6 +117,11 @@
       <groupId>org.apache.commons</groupId>
       <artifactId>commons-collections4</artifactId>
     </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+      <version>${jackson.version}</version>
+    </dependency>
 
     <!-- Logging -->
     <dependency>
diff --git a/oak-store-spi/src/main/java/org/apache/jackrabbit/oak/json/JsonUtils.java b/oak-store-spi/src/main/java/org/apache/jackrabbit/oak/json/JsonUtils.java
new file mode 100644
index 0000000000..590403b8c2
--- /dev/null
+++ b/oak-store-spi/src/main/java/org/apache/jackrabbit/oak/json/JsonUtils.java
@@ -0,0 +1,281 @@
+/*
+ * 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.jackrabbit.oak.json;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.commons.json.JsopReader;
+import org.apache.jackrabbit.oak.commons.json.JsopTokenizer;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class JsonUtils {
+
+    private static final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * Convert a NodeState to a Map representation
+     *
+     * @param nodeState The NodeState to convert
+     * @param maxDepth  Maximum depth to traverse
+     * @return Map representation of the NodeState
+     */
+    public static Map<String, Object> convertNodeStateToMap(NodeState nodeState, int maxDepth, boolean shouldSerializeHiddenNodesOrProperties) {
+        return convertNodeStateToMap(nodeState, maxDepth, -1, shouldSerializeHiddenNodesOrProperties);
+    }
+
+    /**
+     * Convert a NodeState to a Map representation
+     *
+     * @param nodeState    The NodeState to convert
+     * @param maxDepth     Maximum depth to traverse
+     * @param currentDepth Current traversal depth
+     * @return Map representation of the NodeState
+     */
+    private static Map<String, Object> convertNodeStateToMap(NodeState nodeState, int maxDepth, int currentDepth, boolean shouldSerializeHiddenNodesOrProperties) {
+        if (maxDepth >= 0 && currentDepth >= maxDepth) {
+            return null;
+        }
+
+        Map<String, Object> result = new HashMap<>();
+
+        // Convert properties
+        for (PropertyState property : nodeState.getProperties()) {
+            String name = property.getName();
+            Type<?> type = property.getType();
+            // Skip serializing hidden properties.
+            if (!shouldSerializeHiddenNodesOrProperties && name.startsWith(":")) {
+                continue;
+            }
+            if (property.isArray()) {
+                if (type == Type.STRINGS) {
+                    result.put(name, property.getValue(Type.STRINGS));
+                } else if (type == Type.LONGS) {
+                    result.put(name, property.getValue(Type.LONGS));
+                } else if (type == Type.DOUBLES) {
+                    result.put(name, property.getValue(Type.DOUBLES));
+                } else if (type == Type.BOOLEANS) {
+                    result.put(name, property.getValue(Type.BOOLEANS));
+                } else if (type == Type.DATES) {
+                    result.put(name, property.getValue(Type.DATES));
+                } else if (type == Type.DECIMALS) {
+                    result.put(name, property.getValue(Type.DECIMALS));
+                } else {
+                    // For other array types, convert to strings
+                    List<String> values = new ArrayList<>();
+                    for (int i = 0; i < property.count(); i++) {
+                        values.add(property.getValue(Type.STRING, i));
+                    }
+                    result.put(name, values);
+                }
+            } else {
+                if (type == Type.STRING) {
+                    result.put(name, property.getValue(Type.STRING));
+                } else if (type == Type.LONG) {
+                    result.put(name, property.getValue(Type.LONG));
+                } else if (type == Type.DOUBLE) {
+                    result.put(name, property.getValue(Type.DOUBLE));
+                } else if (type == Type.BOOLEAN) {
+                    result.put(name, property.getValue(Type.BOOLEAN));
+                } else if (type == Type.DATE) {
+                    result.put(name, property.getValue(Type.DATE));
+                } else if (type == Type.DECIMAL) {
+                    result.put(name, property.getValue(Type.DECIMAL));
+                } else {
+                    // For other types, convert to string
+                    result.put(name, property.getValue(Type.STRING));
+                }
+            }
+        }
+
+        // Convert child nodes recursively
+        for (String childName : nodeState.getChildNodeNames()) {
+            if (!shouldSerializeHiddenNodesOrProperties && childName.startsWith(":")) {
+                continue;
+            }
+            NodeState childNode = nodeState.getChildNode(childName);
+            Map<String, Object> childMap = convertNodeStateToMap(childNode, maxDepth, currentDepth + 1, shouldSerializeHiddenNodesOrProperties);
+            if (childMap != null) {
+                result.put(childName, childMap);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Converts a NodeState to JSON string with specified depth
+     *
+     * @param nodeState The NodeState to convert
+     * @param maxDepth  Maximum depth to traverse, use -1 for unlimited depth
+     * @return JSON string representation
+     * @throws JsonProcessingException if JSON processing fails
+     */
+    public static String nodeStateToJson(NodeState nodeState, int maxDepth) throws JsonProcessingException {
+        JsonNode jsonNode = convertNodeStateToJson(nodeState, maxDepth, 0);
+        return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
+    }
+
+    private static JsonNode convertNodeStateToJson(NodeState nodeState, int maxDepth, int currentDepth) {
+        ObjectNode result = mapper.createObjectNode();
+
+        // Return if max depth reached
+        if (maxDepth != -1 && currentDepth > maxDepth) {
+            return result;
+        }
+
+        // Convert properties
+        for (PropertyState property : nodeState.getProperties()) {
+            String name = property.getName();
+            Type<?> type = property.getType();
+
+            if (property.isArray()) {
+                ArrayNode arrayNode = mapper.createArrayNode();
+                if (type == Type.STRINGS) {
+                    property.getValue(Type.STRINGS).forEach(arrayNode::add);
+                } else if (type == Type.LONGS) {
+                    property.getValue(Type.LONGS).forEach(arrayNode::add);
+                } else if (type == Type.DOUBLES) {
+                    property.getValue(Type.DOUBLES).forEach(arrayNode::add);
+                } else if (type == Type.BOOLEANS) {
+                    property.getValue(Type.BOOLEANS).forEach(arrayNode::add);
+                } else if (type == Type.DATES) {
+                    property.getValue(Type.DATES).forEach(arrayNode::add);
+                } else if (type == Type.DECIMALS) {
+                    property.getValue(Type.DECIMALS).forEach(arrayNode::add);
+                } else {
+                    // For other array types, convert to strings
+                    for (int i = 0; i < property.count(); i++) {
+                        arrayNode.add(property.getValue(Type.STRING, i));
+                    }
+                }
+                result.set(name, arrayNode);
+            } else {
+                if (type == Type.STRING) {
+                    result.put(name, property.getValue(Type.STRING));
+                } else if (type == Type.LONG) {
+                    result.put(name, property.getValue(Type.LONG));
+                } else if (type == Type.DOUBLE) {
+                    result.put(name, property.getValue(Type.DOUBLE));
+                } else if (type == Type.BOOLEAN) {
+                    result.put(name, property.getValue(Type.BOOLEAN));
+                } else if (type == Type.DATE) {
+                    result.put(name, property.getValue(Type.DATE).toString());
+                } else if (type == Type.DECIMAL) {
+                    result.put(name, property.getValue(Type.DECIMAL).toString());
+                } else {
+                    // For other types, convert to string
+                    result.put(name, property.getValue(Type.STRING));
+                }
+            }
+        }
+
+        // Convert child nodes recursively
+        for (String childName : nodeState.getChildNodeNames()) {
+            NodeState childNode = nodeState.getChildNode(childName);
+            result.set(childName, convertNodeStateToJson(childNode, maxDepth, currentDepth + 1));
+        }
+
+        return result;
+    }
+
+    public static boolean isValidJson(String text, boolean isJsonArray) {
+        if (text == null) {
+            return false;
+        }
+
+        JsopReader reader = new JsopTokenizer(text);
+        return validateJson(reader, isJsonArray);
+    }
+
+    private static boolean validateJson(JsopReader reader, boolean isJsonArray) {
+        if (reader.matches('{')) {
+            return validateObject(reader) && reader.read() == JsopReader.END;
+        }
+        else if (reader.matches('[')) {
+            if (!isJsonArray) {
+                return false;
+            }
+            return validateArray(reader) && reader.read() == JsopReader.END;
+        }
+        else {
+            return false;// readJsonValue(reader) && reader.read() == JsopReader.END;
+        }
+    }
+
+    private static boolean validateObject(JsopReader reader) {
+        boolean first = true;
+        while (!reader.matches('}')) {
+            if (!first && !reader.matches(',')) {
+                return false;
+            }
+            if (!reader.matches(JsopReader.STRING)) {
+                return false;
+            }
+            if (!reader.matches(':')) {
+                return false;
+            }
+            if (!readJsonValue(reader)) {
+                return false;
+            }
+            first = false;
+        }
+        return true;
+    }
+
+    private static boolean validateArray(JsopReader reader) {
+        boolean first = true;
+        while (!reader.matches(']')) {
+            if (!first && !reader.matches(',')) {
+                return false;
+            }
+            if (!readJsonValue(reader)) {
+                return false;
+            }
+            first = false;
+        }
+        return true;
+    }
+
+    private static boolean readJsonValue(JsopReader reader) {
+        int token = reader.read();
+        switch (token) {
+            case JsopReader.STRING:
+            case JsopReader.NUMBER:
+            case JsopReader.TRUE:
+            case JsopReader.FALSE:
+            case JsopReader.NULL:
+                return true;
+            case '{':
+                return validateObject(reader);
+            case '[':
+                return validateArray(reader);
+            default:
+                return false;
+        }
+    }
+}
diff --git a/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsNodeStateToMapTest.java b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsNodeStateToMapTest.java
new file mode 100644
index 0000000000..9a344859f4
--- /dev/null
+++ b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsNodeStateToMapTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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.jackrabbit.oak.json;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.apache.jackrabbit.oak.spi.state.NodeStore;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+
+public class JsonUtilsNodeStateToMapTest {
+    private ObjectMapper mapper = new ObjectMapper();
+
+    @Test
+    public void testEmptyNodeStateToJson() throws JsonProcessingException {
+        // Create an empty node state
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeState emptyNodeState = nodeStore.getRoot().builder().getNodeState();
+        
+        // Convert to JSON
+        String json = JsonUtils.nodeStateToJson(emptyNodeState, -1);
+        
+        // Verify the JSON is well-formed and represents an empty object
+        JsonNode jsonNode = mapper.readTree(json);
+        Assert.assertTrue(jsonNode.isObject());
+        Assert.assertEquals(0, jsonNode.size());
+    }
+    
+    @Test
+    public void testSimpleNodeStateToJson() throws JsonProcessingException {
+        // Create a simple node state with one property
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeBuilder builder = nodeStore.getRoot().builder();
+        builder.setProperty("simpleProperty", "simple value");
+        NodeState simpleNodeState = builder.getNodeState();
+        
+        // Convert to JSON
+        String json = JsonUtils.nodeStateToJson(simpleNodeState, -1);
+        
+        // Verify the JSON
+        JsonNode jsonNode = mapper.readTree(json);
+        Assert.assertTrue(jsonNode.isObject());
+        Assert.assertEquals(1, jsonNode.size());
+        Assert.assertEquals("simple value", jsonNode.get("simpleProperty").asText());
+    }
+    
+    @Test
+    public void testNodeStateWithMultiplePropertyTypes() throws JsonProcessingException {
+        // Create a node state with various property types
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeBuilder builder = nodeStore.getRoot().builder();
+        builder.setProperty("stringProp", "string value");
+        builder.setProperty("booleanProp", true);
+        builder.setProperty("longProp", 1234L);
+        builder.setProperty("doubleProp", 12.34);
+        
+        NodeState multiPropNodeState = builder.getNodeState();
+        
+        // Convert to JSON
+        String json = JsonUtils.nodeStateToJson(multiPropNodeState, -1);
+        
+        // Verify the JSON
+        JsonNode jsonNode = mapper.readTree(json);
+        Assert.assertTrue(jsonNode.isObject());
+        Assert.assertEquals(4, jsonNode.size());
+        Assert.assertEquals("string value", jsonNode.get("stringProp").asText());
+        Assert.assertTrue(jsonNode.get("booleanProp").asBoolean());
+        Assert.assertEquals(1234, jsonNode.get("longProp").asLong());
+        Assert.assertEquals(12.34, jsonNode.get("doubleProp").asDouble(), 0.001);
+    }
+    
+    @Test
+    public void testNodeStateWithArrayProperties() throws JsonProcessingException {
+        // Create a node state with array properties
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeBuilder builder = nodeStore.getRoot().builder();
+        builder.setProperty("stringArray", Arrays.asList("value1", "value2", "value3"), Type.STRINGS);
+        builder.setProperty("longArray", Arrays.asList(1L, 2L, 3L), Type.LONGS);
+        builder.setProperty("booleanArray", Arrays.asList(true, false, true), Type.BOOLEANS);
+        
+        NodeState arrayPropNodeState = builder.getNodeState();
+        
+        // Convert to JSON
+        String json = JsonUtils.nodeStateToJson(arrayPropNodeState, -1);
+        
+        // Verify the JSON
+        JsonNode jsonNode = mapper.readTree(json);
+        Assert.assertTrue(jsonNode.isObject());
+        Assert.assertTrue(jsonNode.get("stringArray").isArray());
+        Assert.assertEquals(3, jsonNode.get("stringArray").size());
+        Assert.assertEquals("value2", jsonNode.get("stringArray").get(1).asText());
+        
+        Assert.assertTrue(jsonNode.get("longArray").isArray());
+        Assert.assertEquals(3, jsonNode.get("longArray").size());
+        Assert.assertEquals(2, jsonNode.get("longArray").get(1).asLong());
+        
+        Assert.assertTrue(jsonNode.get("booleanArray").isArray());
+        Assert.assertEquals(3, jsonNode.get("booleanArray").size());
+        Assert.assertFalse(jsonNode.get("booleanArray").get(1).asBoolean());
+    }
+    
+    @Test
+    public void testNodeStateToJsonWithDepthLimit() throws JsonProcessingException {
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeState root = nodeStore.getRoot();
+        NodeBuilder nodeBuilder = root.builder();
+        nodeBuilder.setChildNode("childNode1")
+            .setProperty("testProperty", "testValue")
+            .setProperty("jcr:primaryType", "nt:base")
+            .setProperty("jcr:created", "2023-10-01T00:00:00.000Z");
+        nodeBuilder.setChildNode("childNode2")
+            .setProperty("testProperty2", "testValue2")
+            .setChildNode("childNode3").setProperty("testProperty3", "testValue3");
+
+        NodeState testNodeState = nodeBuilder.getNodeState();
+        
+        // Test with depth 0 (only the root node, no children)
+        String json0 = JsonUtils.nodeStateToJson(testNodeState, 0);
+        JsonNode jsonNode0 = mapper.readTree(json0);
+        Assert.assertTrue(jsonNode0.isObject());
+        Assert.assertEquals(2, jsonNode0.size());
+        
+        // Test with depth 1 (root + immediate children, but not grandchildren)
+        String json1 = JsonUtils.nodeStateToJson(testNodeState, 1);
+        JsonNode jsonNode1 = mapper.readTree(json1);
+        Assert.assertTrue(jsonNode1.isObject());
+        Assert.assertTrue(jsonNode1.has("childNode1"));
+        Assert.assertTrue(jsonNode1.has("childNode2"));
+        Assert.assertTrue(jsonNode1.get("childNode2").has("childNode3"));
+        Assert.assertEquals(0, jsonNode1.get("childNode2").get("childNode3").size());
+
+        // Convert to JSON with unlimited depth
+        String json = JsonUtils.nodeStateToJson(testNodeState, -1);
+
+        // Verify the JSON structure
+        JsonNode jsonNode = mapper.readTree(json);
+        Assert.assertTrue(jsonNode.isObject());
+        Assert.assertTrue(jsonNode.has("childNode1"));
+        Assert.assertTrue(jsonNode.has("childNode2"));
+
+        JsonNode childNode1 = jsonNode.get("childNode1");
+        Assert.assertEquals("testValue", childNode1.get("testProperty").asText());
+        Assert.assertEquals("nt:base", childNode1.get("jcr:primaryType").asText());
+
+        JsonNode childNode2 = jsonNode.get("childNode2");
+        Assert.assertEquals("testValue2", childNode2.get("testProperty2").asText());
+        Assert.assertTrue(childNode2.has("childNode3"));
+        Assert.assertEquals("testValue3", childNode2.get("childNode3").get("testProperty3").asText());
+    }
+    
+    @Test
+    public void testComplexNodeStateWithNestedStructure() throws JsonProcessingException {
+        // Create a complex node structure
+        NodeStore nodeStore = new MemoryNodeStore();
+        NodeBuilder builder = nodeStore.getRoot().builder();
+
+
+        // Root level properties
+        builder.setProperty("rootProp1", "root value");
+        builder.setProperty("rootProp2", 100);
+
+        // Level 1: Category node
+        NodeBuilder categoryBuilder = builder.setChildNode("categories");
+        categoryBuilder.setProperty("description", "Product categories");
+
+        // Level 2: Individual categories
+        NodeBuilder electronics = categoryBuilder.setChildNode("electronics");
+        electronics.setProperty("displayName", "Electronics");
+        electronics.setProperty("active", true);
+
+        NodeBuilder clothing = categoryBuilder.setChildNode("clothing");
+        clothing.setProperty("displayName", "Clothing");
+        clothing.setProperty("active", false);
+
+        // Level 3: Subcategories
+        NodeBuilder phones = electronics.setChildNode("phones");
+        phones.setProperty("displayName", "Mobile Phones");
+        phones.setProperty("items", Arrays.asList(1001L, 1002L, 1003L), Type.LONGS);
+
+        NodeBuilder laptops = electronics.setChildNode("laptops");
+        laptops.setProperty("displayName", "Laptops");
+        laptops.setProperty("featured", true);
+
+        NodeState complexNodeState = builder.getNodeState();
+        // Convert to JSON
+        String json = JsonUtils.nodeStateToJson(complexNodeState, -1);
+        // Verify the complex JSON structure
+        JsonNode jsonNode = mapper.readTree(json);
+        
+        // Root level verification
+        Assert.assertEquals("root value", jsonNode.get("rootProp1").asText());
+        Assert.assertEquals(100, jsonNode.get("rootProp2").asInt());
+        
+        // Level 1 verification
+        JsonNode categoriesJsonNode = jsonNode.get("categories");
+        Assert.assertNotNull(categoriesJsonNode);
+        Assert.assertEquals("Product categories", categoriesJsonNode.get("description").asText());
+        
+        // Level 2 verification
+        JsonNode electronicsJsonNode = categoriesJsonNode.get("electronics");
+        Assert.assertNotNull(electronicsJsonNode);
+        Assert.assertEquals("Electronics", electronicsJsonNode.get("displayName").asText());
+        Assert.assertTrue(electronicsJsonNode.get("active").asBoolean());
+        
+        JsonNode clothingJsonNode = categoriesJsonNode.get("clothing");
+        Assert.assertNotNull(clothingJsonNode);
+        Assert.assertEquals("Clothing", clothingJsonNode.get("displayName").asText());
+        Assert.assertFalse(clothingJsonNode.get("active").asBoolean());
+        
+        // Level 3 verification
+        JsonNode phonesJsonNode = electronicsJsonNode.get("phones");
+        Assert.assertNotNull(phonesJsonNode);
+        Assert.assertEquals("Mobile Phones", phonesJsonNode.get("displayName").asText());
+        Assert.assertTrue(phonesJsonNode.get("items").isArray());
+        Assert.assertEquals(3, phonesJsonNode.get("items").size());
+        Assert.assertEquals(1002, phonesJsonNode.get("items").get(1).asInt());
+        
+        JsonNode laptopsJsonNode = electronicsJsonNode.get("laptops");
+        Assert.assertNotNull(laptopsJsonNode);
+        Assert.assertEquals("Laptops", laptopsJsonNode.get("displayName").asText());
+        Assert.assertTrue(laptopsJsonNode.get("featured").asBoolean());
+    }
+}
diff --git a/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java
new file mode 100644
index 0000000000..c3848bbea0
--- /dev/null
+++ b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java
@@ -0,0 +1,270 @@
+/*
+ * 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.jackrabbit.oak.json;
+
+import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState;
+import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class JsonUtilsTest {
+
+    @Test
+    public void testNullInput() {
+        assertFalse(JsonUtils.isValidJson(null, false));
+        assertFalse(JsonUtils.isValidJson(null, true));
+    }
+
+    @Test
+    public void testEmptyString() {
+        assertFalse(JsonUtils.isValidJson("", false));
+        assertFalse(JsonUtils.isValidJson("", true));
+    }
+
+    @Test
+    public void testValidJsonObject() {
+        assertTrue(JsonUtils.isValidJson("{}", false));
+        assertTrue(JsonUtils.isValidJson("{\"key\":\"value\"}", false));
+        assertTrue(JsonUtils.isValidJson("{\"key\":123}", false));
+        assertTrue(JsonUtils.isValidJson("{\"key\":true}", false));
+        assertTrue(JsonUtils.isValidJson("{\"key\":null}", false));
+        assertTrue(JsonUtils.isValidJson("{\"key\":{\"nested\":\"value\"}}", false));
+    }
+
+    @Test
+    public void testInvalidJsonObject() {
+        assertFalse(JsonUtils.isValidJson("{", false));
+        assertFalse(JsonUtils.isValidJson("}", false));
+        assertFalse(JsonUtils.isValidJson("{key:value}", false));
+        assertFalse(JsonUtils.isValidJson("{\"key\":value}", false));
+        assertFalse(JsonUtils.isValidJson("{\"key\":\"value\"", false));
+        assertFalse(JsonUtils.isValidJson("{\"key\":\"value\",}", false));
+    }
+
+    @Test
+    public void testValidJsonArray() {
+        assertTrue(JsonUtils.isValidJson("[]", true));
+        assertTrue(JsonUtils.isValidJson("[1,2,3]", true));
+        assertTrue(JsonUtils.isValidJson("[\"a\",\"b\",\"c\"]", true));
+        assertTrue(JsonUtils.isValidJson("[true,false,null]", true));
+        assertTrue(JsonUtils.isValidJson("[{\"key\":\"value\"},{\"key2\":123}]", true));
+    }
+
+    @Test
+    public void testInvalidJsonArray() {
+        assertFalse(JsonUtils.isValidJson("[", true));
+        assertFalse(JsonUtils.isValidJson("]", true));
+        assertFalse(JsonUtils.isValidJson("[1,2,]", true));
+        assertFalse(JsonUtils.isValidJson("[1 2 3]", true));
+        assertFalse(JsonUtils.isValidJson("[\"unclosed]", true));
+    }
+
+    @Test
+    public void testArrayNotAllowedWhenFlagIsFalse() {
+        assertFalse(JsonUtils.isValidJson("[]", false));
+        assertFalse(JsonUtils.isValidJson("[1,2,3]", false));
+    }
+
+    @Test
+    public void testValidPrimitiveValues() {
+        assertFalse(JsonUtils.isValidJson("123", false));
+        assertFalse(JsonUtils.isValidJson("\"string\"", false));
+        assertFalse(JsonUtils.isValidJson("true", false));
+        assertFalse(JsonUtils.isValidJson("false", false));
+        assertFalse(JsonUtils.isValidJson("null", false));
+    }
+
+    @Test
+    public void testInvalidPrimitiveValues() {
+        assertFalse(JsonUtils.isValidJson("undefined", false));
+        assertFalse(JsonUtils.isValidJson("'string'", false));
+        assertFalse(JsonUtils.isValidJson("TRUE", false));
+        assertFalse(JsonUtils.isValidJson("False", false));
+        assertFalse(JsonUtils.isValidJson("NULL", false));
+    }
+
+    @Test
+    public void testComplexNestedStructures() {
+        assertTrue(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":true}]}", false));
+        assertTrue(JsonUtils.isValidJson("[{\"obj\":{}},[[],{}]]", true));
+        assertTrue(JsonUtils.isValidJson("{\"a\":{\"b\":{\"c\":{\"d\":null}}}}", false));
+        assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":Undefined}]}", false));
+        assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":NaN}]}", false));
+        assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":Infinity}]}", false));
+        assertFalse(JsonUtils.isValidJson("{\"a\":{\"b\":{\"c\":{\"d\":void}}}}", false));
+    }
+
+    @Test
+    public void testConvertNodeStateToMap() {
+        MemoryNodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        builder.setProperty("property1", "value1");
+        builder.setProperty("property2", 123);
+        NodeState nodeState = builder.getNodeState();
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(nodeState, 2, true);
+
+        assertNotNull(result);
+        assertEquals(2, result.size());
+        assertTrue(result.containsKey("property1"));
+        assertTrue(result.containsKey("property2"));
+        assertEquals("value1", result.get("property1"));
+        assertEquals(123L, result.get("property2"));
+    }
+
+    @Test
+    public void testConvertNodeStateToMapWithMaxDepth() {
+        MemoryNodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        builder.setProperty("property1", "value1");
+        NodeState nodeState = builder.getNodeState();
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(nodeState, 0, true);
+        assertNotNull(result);
+    }
+
+    @Test
+    public void testConvertNodeStateToMapWithNestedNodes() {
+        NodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        builder.setProperty("prop1", "val1");
+
+        NodeBuilder child = builder.child("child1");
+        child.setProperty("childProp1", "childVal1");
+
+        NodeBuilder grandChild = child.child("grandChild1");
+        grandChild.setProperty("grandChildProp1", "grandChildVal1");
+
+        NodeBuilder greatGrandChild = grandChild.child("greatGrandChild1");
+        greatGrandChild.setProperty("greatGrandChildProp1", "greatGrandChildVal1");
+
+        NodeState nodeState = builder.getNodeState();
+
+        // Test with maxDepth = 0 (should return only properties on current node)
+        Map<String, Object> result0 = JsonUtils.convertNodeStateToMap(nodeState, 0, true);
+        assertNotNull(result0);
+        assertEquals(1, result0.size());
+        assertTrue(result0.containsKey("prop1"));
+        assertEquals("val1", result0.get("prop1"));
+        assertNotNull(result0);
+        assertTrue(result0.containsKey("prop1"));
+        assertFalse(result0.containsKey("child1"));
+
+
+        // Test with maxDepth = 1 (should include child1)
+        Map<String, Object> result1 = JsonUtils.convertNodeStateToMap(nodeState, 1, true);
+        assertNotNull(result1);
+        assertTrue(result1.containsKey("child1"));
+        Map<String, Object> childMap1 = (Map<String, Object>) result1.get("child1");
+        assertEquals("childVal1", childMap1.get("childProp1"));
+        assertNotNull(result1);
+        assertTrue(result1.containsKey("prop1"));
+        assertTrue(result1.containsKey("child1"));
+        assertFalse(childMap1.containsKey("grandChild1"));
+
+
+        // Test with maxDepth = 2 (should include grandChild1)
+        Map<String, Object> result2 = JsonUtils.convertNodeStateToMap(nodeState, 2, true);
+        Map<String, Object> childMap2 = (Map<String, Object>) result2.get("child1");
+        assertTrue(childMap2.containsKey("grandChild1"));
+        Map<String, Object> grandChildMap2 = (Map<String, Object>) childMap2.get("grandChild1");
+        assertEquals("grandChildVal1", grandChildMap2.get("grandChildProp1"));
+        assertNotNull(result2);
+        assertTrue(result2.containsKey("prop1"));
+        assertTrue(result2.containsKey("child1"));
+        assertFalse(grandChildMap2.containsKey("greatGrandChild1"));
+
+        // Test with maxDepth = 3 (should include greatGrandChild1)
+        Map<String, Object> result3 = JsonUtils.convertNodeStateToMap(nodeState, 3, true);
+        Map<String, Object> childMap3 = (Map<String, Object>) result3.get("child1");
+        Map<String, Object> grandChildMap3 = (Map<String, Object>) childMap3.get("grandChild1");
+        Map<String, Object> greatGrandChildMap3 = (Map<String, Object>) grandChildMap3.get("greatGrandChild1");
+        assertEquals("greatGrandChildVal1", greatGrandChildMap3.get("greatGrandChildProp1"));
+        assertNotNull(result3);
+        assertTrue(result3.containsKey("prop1"));
+        assertTrue(result3.containsKey("child1"));
+
+        // Test with maxDepth = -1 (should return all nodes and properties)
+        Map<String, Object> resultNeg1 = JsonUtils.convertNodeStateToMap(nodeState, -1, true);
+        Map<String, Object> childMapNeg1 = (Map<String, Object>) resultNeg1.get("child1");
+        Map<String, Object> grandChildMapNeg1 = (Map<String, Object>) childMapNeg1.get("grandChild1");
+        assertTrue(grandChildMapNeg1.containsKey("greatGrandChild1"));
+        Map<String, Object> greatGrandChildMapNeg1 = (Map<String, Object>) grandChildMapNeg1.get("greatGrandChild1");
+        assertEquals("greatGrandChildVal1", greatGrandChildMapNeg1.get("greatGrandChildProp1"));
+        assertNotNull(resultNeg1);
+        assertTrue(resultNeg1.containsKey("prop1"));
+        assertTrue(resultNeg1.containsKey("child1"));
+
+
+    }
+
+    @Test
+    public void testConvertNodeStateToMapWithHiddenNodesAndProperties() {
+        NodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+
+        // Regular property
+        builder.setProperty("regularProp", "regularValue");
+
+        // Hidden property (starts with ":")
+        builder.setProperty(":hiddenProp", "hiddenValue");
+
+        // Regular child
+        NodeBuilder regularChild = builder.child("regularChild");
+        regularChild.setProperty("childProp", "childValue");
+
+        // Hidden child (starts with ":")
+        NodeBuilder hiddenChild = builder.child(":hiddenChild");
+        hiddenChild.setProperty("hiddenChildProp", "hiddenChildValue");
+
+        NodeState nodeState = builder.getNodeState();
+
+        // Test with shouldSerializeHiddenNodesOrProperties = true
+        // Hidden nodes and properties should be excluded
+        Map<String, Object> resultWithHiddenExcluded = JsonUtils.convertNodeStateToMap(nodeState, -1, false);
+        assertNotNull(resultWithHiddenExcluded);
+        assertEquals(2, resultWithHiddenExcluded.size());
+        assertTrue(resultWithHiddenExcluded.containsKey("regularProp"));
+        assertTrue(resultWithHiddenExcluded.containsKey("regularChild"));
+        assertFalse(resultWithHiddenExcluded.containsKey(":hiddenProp"));
+        assertFalse(resultWithHiddenExcluded.containsKey(":hiddenChild"));
+
+        // Test with shouldSerializeHiddenNodesOrProperties = false
+        // Hidden nodes and properties should be included
+        Map<String, Object> resultWithHiddenIncluded = JsonUtils.convertNodeStateToMap(nodeState, -1, true);
+        assertNotNull(resultWithHiddenIncluded);
+        assertEquals(4, resultWithHiddenIncluded.size());
+        assertTrue(resultWithHiddenIncluded.containsKey("regularProp"));
+        assertTrue(resultWithHiddenIncluded.containsKey("regularChild"));
+        assertTrue(resultWithHiddenIncluded.containsKey(":hiddenProp"));
+        assertTrue(resultWithHiddenIncluded.containsKey(":hiddenChild"));
+        assertEquals("hiddenValue", resultWithHiddenIncluded.get(":hiddenProp"));
+
+        // Verify nested content
+        Map<String, Object> regularChildMap = (Map<String, Object>) resultWithHiddenIncluded.get("regularChild");
+        assertEquals("childValue", regularChildMap.get("childProp"));
+
+        Map<String, Object> hiddenChildMap = (Map<String, Object>) resultWithHiddenIncluded.get(":hiddenChild");
+        assertEquals("hiddenChildValue", hiddenChildMap.get("hiddenChildProp"));
+    }
+}


(jackrabbit-oak) 02/02: OAK-11692: Added more tests in JsonUtilsTest

Posted by mk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

mkataria pushed a commit to branch OAK-11692_2
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit 03411073bbc5351e2e45d05ab6adfbd9bf9d0584
Author: Mohit Kataria <ti...@gmail.com>
AuthorDate: Thu May 8 21:42:54 2025 +0530

    OAK-11692: Added more tests in JsonUtilsTest
---
 .../apache/jackrabbit/oak/json/JsonUtilsTest.java  | 468 +++++++++++++++------
 1 file changed, 330 insertions(+), 138 deletions(-)

diff --git a/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java
index c3848bbea0..9823793f93 100644
--- a/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java
+++ b/oak-store-spi/src/test/java/org/apache/jackrabbit/oak/json/JsonUtilsTest.java
@@ -18,21 +18,53 @@
  */
 package org.apache.jackrabbit.oak.json;
 
+import org.apache.jackrabbit.oak.api.Type;
 import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState;
 import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeBuilder;
 import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
 import org.apache.jackrabbit.oak.spi.state.NodeState;
 import org.junit.Test;
 
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 public class JsonUtilsTest {
 
+    // Helper methods to reduce duplication
+    private NodeBuilder createNodeBuilder() {
+        return new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+    }
+
+    private <T> void assertPropertyValue(Map<String, Object> result, String propertyName, T expectedValue) {
+        assertNotNull(result);
+        assertTrue("Missing property: " + propertyName, result.containsKey(propertyName));
+        assertEquals("Property value mismatch for " + propertyName, expectedValue, result.get(propertyName));
+    }
+
+    private <T> void assertArrayValues(Map<String, Object> result, String propertyName, List<T> expectedValues) {
+        assertNotNull(result);
+        assertTrue("Missing array property: " + propertyName, result.containsKey(propertyName));
+
+        List<T> resultArray = (List<T>) result.get(propertyName);
+        assertNotNull(resultArray);
+        assertEquals("Array size mismatch for " + propertyName, expectedValues.size(), resultArray.size());
+
+        for (int i = 0; i < expectedValues.size(); i++) {
+            assertEquals("Value mismatch at index " + i + " for " + propertyName,
+                         expectedValues.get(i), resultArray.get(i));
+        }
+    }
+
+    // JSON validation tests
     @Test
     public void testNullInput() {
         assertFalse(JsonUtils.isValidJson(null, false));
@@ -47,40 +79,64 @@ public class JsonUtilsTest {
 
     @Test
     public void testValidJsonObject() {
-        assertTrue(JsonUtils.isValidJson("{}", false));
-        assertTrue(JsonUtils.isValidJson("{\"key\":\"value\"}", false));
-        assertTrue(JsonUtils.isValidJson("{\"key\":123}", false));
-        assertTrue(JsonUtils.isValidJson("{\"key\":true}", false));
-        assertTrue(JsonUtils.isValidJson("{\"key\":null}", false));
-        assertTrue(JsonUtils.isValidJson("{\"key\":{\"nested\":\"value\"}}", false));
+        String[] validObjects = {
+            "{}",
+            "{\"key\":\"value\"}",
+            "{\"key\":123}",
+            "{\"key\":true}",
+            "{\"key\":null}",
+            "{\"key\":{\"nested\":\"value\"}}"
+        };
+
+        for (String json : validObjects) {
+            assertTrue("Should be valid JSON: " + json, JsonUtils.isValidJson(json, false));
+        }
     }
 
     @Test
     public void testInvalidJsonObject() {
-        assertFalse(JsonUtils.isValidJson("{", false));
-        assertFalse(JsonUtils.isValidJson("}", false));
-        assertFalse(JsonUtils.isValidJson("{key:value}", false));
-        assertFalse(JsonUtils.isValidJson("{\"key\":value}", false));
-        assertFalse(JsonUtils.isValidJson("{\"key\":\"value\"", false));
-        assertFalse(JsonUtils.isValidJson("{\"key\":\"value\",}", false));
+        String[] invalidObjects = {
+            "{",
+            "}",
+            "{key:value}",
+            "{\"key\":value}",
+            "{\"key\":\"value\"",
+            "{\"key\":\"value\",}"
+        };
+
+        for (String json : invalidObjects) {
+            assertFalse("Should be invalid JSON: " + json, JsonUtils.isValidJson(json, false));
+        }
     }
 
     @Test
     public void testValidJsonArray() {
-        assertTrue(JsonUtils.isValidJson("[]", true));
-        assertTrue(JsonUtils.isValidJson("[1,2,3]", true));
-        assertTrue(JsonUtils.isValidJson("[\"a\",\"b\",\"c\"]", true));
-        assertTrue(JsonUtils.isValidJson("[true,false,null]", true));
-        assertTrue(JsonUtils.isValidJson("[{\"key\":\"value\"},{\"key2\":123}]", true));
+        String[] validArrays = {
+            "[]",
+            "[1,2,3]",
+            "[\"a\",\"b\",\"c\"]",
+            "[true,false,null]",
+            "[{\"key\":\"value\"},{\"key2\":123}]"
+        };
+
+        for (String json : validArrays) {
+            assertTrue("Should be valid JSON array: " + json, JsonUtils.isValidJson(json, true));
+        }
     }
 
     @Test
     public void testInvalidJsonArray() {
-        assertFalse(JsonUtils.isValidJson("[", true));
-        assertFalse(JsonUtils.isValidJson("]", true));
-        assertFalse(JsonUtils.isValidJson("[1,2,]", true));
-        assertFalse(JsonUtils.isValidJson("[1 2 3]", true));
-        assertFalse(JsonUtils.isValidJson("[\"unclosed]", true));
+        String[] invalidArrays = {
+            "[",
+            "]",
+            "[1,2,]",
+            "[1 2 3]",
+            "[\"unclosed]"
+        };
+
+        for (String json : invalidArrays) {
+            assertFalse("Should be invalid JSON array: " + json, JsonUtils.isValidJson(json, true));
+        }
     }
 
     @Test
@@ -91,63 +147,59 @@ public class JsonUtilsTest {
 
     @Test
     public void testValidPrimitiveValues() {
-        assertFalse(JsonUtils.isValidJson("123", false));
-        assertFalse(JsonUtils.isValidJson("\"string\"", false));
-        assertFalse(JsonUtils.isValidJson("true", false));
-        assertFalse(JsonUtils.isValidJson("false", false));
-        assertFalse(JsonUtils.isValidJson("null", false));
-    }
-
-    @Test
-    public void testInvalidPrimitiveValues() {
-        assertFalse(JsonUtils.isValidJson("undefined", false));
-        assertFalse(JsonUtils.isValidJson("'string'", false));
-        assertFalse(JsonUtils.isValidJson("TRUE", false));
-        assertFalse(JsonUtils.isValidJson("False", false));
-        assertFalse(JsonUtils.isValidJson("NULL", false));
+        String[] primitives = {
+            "123",
+            "\"string\"",
+            "true",
+            "false",
+            "null"
+        };
+
+        for (String json : primitives) {
+            assertFalse("Primitive values should not be valid JSON objects: " + json,
+                       JsonUtils.isValidJson(json, false));
+        }
     }
 
     @Test
     public void testComplexNestedStructures() {
+        // Valid structures
         assertTrue(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":true}]}", false));
         assertTrue(JsonUtils.isValidJson("[{\"obj\":{}},[[],{}]]", true));
         assertTrue(JsonUtils.isValidJson("{\"a\":{\"b\":{\"c\":{\"d\":null}}}}", false));
+
+        // Invalid structures
         assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":Undefined}]}", false));
         assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":NaN}]}", false));
         assertFalse(JsonUtils.isValidJson("{\"array\":[1,2,{\"nested\":Infinity}]}", false));
         assertFalse(JsonUtils.isValidJson("{\"a\":{\"b\":{\"c\":{\"d\":void}}}}", false));
     }
 
+    // NodeState to Map conversion tests
     @Test
     public void testConvertNodeStateToMap() {
-        MemoryNodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        NodeBuilder builder = createNodeBuilder();
         builder.setProperty("property1", "value1");
         builder.setProperty("property2", 123);
-        NodeState nodeState = builder.getNodeState();
 
-        Map<String, Object> result = JsonUtils.convertNodeStateToMap(nodeState, 2, true);
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), 2, true);
 
-        assertNotNull(result);
-        assertEquals(2, result.size());
-        assertTrue(result.containsKey("property1"));
-        assertTrue(result.containsKey("property2"));
-        assertEquals("value1", result.get("property1"));
-        assertEquals(123L, result.get("property2"));
+        assertPropertyValue(result, "property1", "value1");
+        assertPropertyValue(result, "property2", 123L);
     }
 
     @Test
     public void testConvertNodeStateToMapWithMaxDepth() {
-        MemoryNodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        NodeBuilder builder = createNodeBuilder();
         builder.setProperty("property1", "value1");
-        NodeState nodeState = builder.getNodeState();
 
-        Map<String, Object> result = JsonUtils.convertNodeStateToMap(nodeState, 0, true);
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), 0, true);
         assertNotNull(result);
     }
 
     @Test
     public void testConvertNodeStateToMapWithNestedNodes() {
-        NodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        NodeBuilder builder = createNodeBuilder();
         builder.setProperty("prop1", "val1");
 
         NodeBuilder child = builder.child("child1");
@@ -161,110 +213,250 @@ public class JsonUtilsTest {
 
         NodeState nodeState = builder.getNodeState();
 
-        // Test with maxDepth = 0 (should return only properties on current node)
-        Map<String, Object> result0 = JsonUtils.convertNodeStateToMap(nodeState, 0, true);
-        assertNotNull(result0);
-        assertEquals(1, result0.size());
-        assertTrue(result0.containsKey("prop1"));
-        assertEquals("val1", result0.get("prop1"));
-        assertNotNull(result0);
-        assertTrue(result0.containsKey("prop1"));
-        assertFalse(result0.containsKey("child1"));
-
-
-        // Test with maxDepth = 1 (should include child1)
-        Map<String, Object> result1 = JsonUtils.convertNodeStateToMap(nodeState, 1, true);
-        assertNotNull(result1);
-        assertTrue(result1.containsKey("child1"));
-        Map<String, Object> childMap1 = (Map<String, Object>) result1.get("child1");
-        assertEquals("childVal1", childMap1.get("childProp1"));
-        assertNotNull(result1);
-        assertTrue(result1.containsKey("prop1"));
-        assertTrue(result1.containsKey("child1"));
-        assertFalse(childMap1.containsKey("grandChild1"));
-
-
-        // Test with maxDepth = 2 (should include grandChild1)
-        Map<String, Object> result2 = JsonUtils.convertNodeStateToMap(nodeState, 2, true);
-        Map<String, Object> childMap2 = (Map<String, Object>) result2.get("child1");
-        assertTrue(childMap2.containsKey("grandChild1"));
-        Map<String, Object> grandChildMap2 = (Map<String, Object>) childMap2.get("grandChild1");
-        assertEquals("grandChildVal1", grandChildMap2.get("grandChildProp1"));
-        assertNotNull(result2);
-        assertTrue(result2.containsKey("prop1"));
-        assertTrue(result2.containsKey("child1"));
-        assertFalse(grandChildMap2.containsKey("greatGrandChild1"));
-
-        // Test with maxDepth = 3 (should include greatGrandChild1)
-        Map<String, Object> result3 = JsonUtils.convertNodeStateToMap(nodeState, 3, true);
-        Map<String, Object> childMap3 = (Map<String, Object>) result3.get("child1");
-        Map<String, Object> grandChildMap3 = (Map<String, Object>) childMap3.get("grandChild1");
-        Map<String, Object> greatGrandChildMap3 = (Map<String, Object>) grandChildMap3.get("greatGrandChild1");
-        assertEquals("greatGrandChildVal1", greatGrandChildMap3.get("greatGrandChildProp1"));
-        assertNotNull(result3);
-        assertTrue(result3.containsKey("prop1"));
-        assertTrue(result3.containsKey("child1"));
-
-        // Test with maxDepth = -1 (should return all nodes and properties)
-        Map<String, Object> resultNeg1 = JsonUtils.convertNodeStateToMap(nodeState, -1, true);
-        Map<String, Object> childMapNeg1 = (Map<String, Object>) resultNeg1.get("child1");
-        Map<String, Object> grandChildMapNeg1 = (Map<String, Object>) childMapNeg1.get("grandChild1");
-        assertTrue(grandChildMapNeg1.containsKey("greatGrandChild1"));
-        Map<String, Object> greatGrandChildMapNeg1 = (Map<String, Object>) grandChildMapNeg1.get("greatGrandChild1");
-        assertEquals("greatGrandChildVal1", greatGrandChildMapNeg1.get("greatGrandChildProp1"));
-        assertNotNull(resultNeg1);
-        assertTrue(resultNeg1.containsKey("prop1"));
-        assertTrue(resultNeg1.containsKey("child1"));
-
-
+        // Test different depth limits
+        int[] depthLimits = {0, 1, 2, 3, -1};
+        for (int depth : depthLimits) {
+            Map<String, Object> result = JsonUtils.convertNodeStateToMap(nodeState, depth, true);
+            assertNotNull(result);
+            assertTrue(result.containsKey("prop1"));
+
+            if (depth != 0) {
+                assertTrue(result.containsKey("child1"));
+                Map<String, Object> childMap = (Map<String, Object>) result.get("child1");
+                assertTrue(childMap.containsKey("childProp1"));
+
+                if (depth > 1 || depth == -1) {
+                    assertTrue(childMap.containsKey("grandChild1"));
+                    Map<String, Object> grandChildMap = (Map<String, Object>) childMap.get("grandChild1");
+                    assertTrue(grandChildMap.containsKey("grandChildProp1"));
+
+                    if (depth > 2 || depth == -1) {
+                        assertTrue(grandChildMap.containsKey("greatGrandChild1"));
+                        Map<String, Object> greatGrandChildMap = (Map<String, Object>) grandChildMap.get("greatGrandChild1");
+                        assertTrue(greatGrandChildMap.containsKey("greatGrandChildProp1"));
+                    }
+                }
+            }
+        }
     }
 
     @Test
     public void testConvertNodeStateToMapWithHiddenNodesAndProperties() {
-        NodeBuilder builder = new MemoryNodeBuilder(EmptyNodeState.EMPTY_NODE);
+        NodeBuilder builder = createNodeBuilder();
 
-        // Regular property
+        // Regular property and child
         builder.setProperty("regularProp", "regularValue");
-
-        // Hidden property (starts with ":")
-        builder.setProperty(":hiddenProp", "hiddenValue");
-
-        // Regular child
         NodeBuilder regularChild = builder.child("regularChild");
         regularChild.setProperty("childProp", "childValue");
 
-        // Hidden child (starts with ":")
+        // Hidden property and child (starts with ":")
+        builder.setProperty(":hiddenProp", "hiddenValue");
         NodeBuilder hiddenChild = builder.child(":hiddenChild");
         hiddenChild.setProperty("hiddenChildProp", "hiddenChildValue");
 
         NodeState nodeState = builder.getNodeState();
 
+        // Test with shouldSerializeHiddenNodesOrProperties = false
+        Map<String, Object> resultHidden = JsonUtils.convertNodeStateToMap(nodeState, -1, false);
+        assertNotNull(resultHidden);
+        assertEquals(2, resultHidden.size());
+        assertTrue(resultHidden.containsKey("regularProp"));
+        assertTrue(resultHidden.containsKey("regularChild"));
+        assertFalse(resultHidden.containsKey(":hiddenProp"));
+        assertFalse(resultHidden.containsKey(":hiddenChild"));
+
         // Test with shouldSerializeHiddenNodesOrProperties = true
-        // Hidden nodes and properties should be excluded
-        Map<String, Object> resultWithHiddenExcluded = JsonUtils.convertNodeStateToMap(nodeState, -1, false);
-        assertNotNull(resultWithHiddenExcluded);
-        assertEquals(2, resultWithHiddenExcluded.size());
-        assertTrue(resultWithHiddenExcluded.containsKey("regularProp"));
-        assertTrue(resultWithHiddenExcluded.containsKey("regularChild"));
-        assertFalse(resultWithHiddenExcluded.containsKey(":hiddenProp"));
-        assertFalse(resultWithHiddenExcluded.containsKey(":hiddenChild"));
+        Map<String, Object> resultVisible = JsonUtils.convertNodeStateToMap(nodeState, -1, true);
+        assertNotNull(resultVisible);
+        assertEquals(4, resultVisible.size());
+        assertTrue(resultVisible.containsKey("regularProp"));
+        assertTrue(resultVisible.containsKey("regularChild"));
+        assertTrue(resultVisible.containsKey(":hiddenProp"));
+        assertTrue(resultVisible.containsKey(":hiddenChild"));
+    }
 
-        // Test with shouldSerializeHiddenNodesOrProperties = false
-        // Hidden nodes and properties should be included
-        Map<String, Object> resultWithHiddenIncluded = JsonUtils.convertNodeStateToMap(nodeState, -1, true);
-        assertNotNull(resultWithHiddenIncluded);
-        assertEquals(4, resultWithHiddenIncluded.size());
-        assertTrue(resultWithHiddenIncluded.containsKey("regularProp"));
-        assertTrue(resultWithHiddenIncluded.containsKey("regularChild"));
-        assertTrue(resultWithHiddenIncluded.containsKey(":hiddenProp"));
-        assertTrue(resultWithHiddenIncluded.containsKey(":hiddenChild"));
-        assertEquals("hiddenValue", resultWithHiddenIncluded.get(":hiddenProp"));
-
-        // Verify nested content
-        Map<String, Object> regularChildMap = (Map<String, Object>) resultWithHiddenIncluded.get("regularChild");
-        assertEquals("childValue", regularChildMap.get("childProp"));
-
-        Map<String, Object> hiddenChildMap = (Map<String, Object>) resultWithHiddenIncluded.get(":hiddenChild");
-        assertEquals("hiddenChildValue", hiddenChildMap.get("hiddenChildProp"));
+    // Type-specific property tests
+    @Test
+    public void testNodeStateWithDecimalValues() {
+        NodeBuilder builder = createNodeBuilder();
+
+        // Single decimal property
+        BigDecimal singleDecimal = new BigDecimal("123.456");
+        builder.setProperty("decimalProp", singleDecimal);
+
+        // Array of decimal values
+        List<BigDecimal> decimalValues = Arrays.asList(
+                new BigDecimal("1.1"),
+                new BigDecimal("2.2"),
+                new BigDecimal("3.3"),
+                new BigDecimal("9999.9999")
+        );
+        builder.setProperty("decimalArray", decimalValues, Type.DECIMALS);
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), -1, true);
+
+        assertPropertyValue(result, "decimalProp", singleDecimal);
+        assertArrayValues(result, "decimalArray", decimalValues);
+    }
+
+    @Test
+    public void testNodeStateWithDateValues() {
+        NodeBuilder builder = createNodeBuilder();
+
+        // Single date property
+        Calendar date1 = Calendar.getInstance();
+        builder.setProperty("dateProp", date1);
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), -1, true);
+
+        assertTrue(result.containsKey("dateProp"));
+
+        // Date property should be a String (ISO formatted date)
+        Object dateValue = result.get("dateProp");
+        assertTrue("Date value is of type " + dateValue.getClass().getName(),
+                  dateValue instanceof String);
+        // Verify it looks like an ISO date string
+        String dateStr = (String) dateValue;
+        assertTrue("Date value should be in ISO format: " + dateStr,
+                dateStr.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*"));
+    }
+
+    @Test
+    public void testNodeStateWithDateArrayValues() {
+        NodeBuilder builder = createNodeBuilder();
+
+        // String ISO dates for array
+        List<String> dateStrings = Arrays.asList(
+            "2021-01-01T10:30:00.000Z",
+            "2022-02-02T12:45:00.000Z",
+            "2023-03-03T14:15:00.000Z"
+        );
+
+        builder.setProperty("dateStringArray", dateStrings, Type.DATES);
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), -1, true);
+
+        assertTrue(result.containsKey("dateStringArray"));
+
+        // Check date array contains String objects (ISO format dates)
+        List<?> resultArray = (List<?>) result.get("dateStringArray");
+        assertNotNull(resultArray);
+        assertEquals(3, resultArray.size());
+
+        for (Object date : resultArray) {
+            assertTrue("Date array element is of type " + date.getClass().getName(),
+                     date instanceof String);
+            String dateStr = (String) date;
+            assertTrue("Date array value should be in ISO format: " + dateStr,
+                    dateStr.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*"));
+        }
+    }
+
+    @Test
+    public void testNodeStateWithPrimitiveValues() {
+        NodeBuilder builder = createNodeBuilder();
+
+        // Boolean properties
+        builder.setProperty("boolTrue", true);
+        builder.setProperty("boolFalse", false);
+        List<Boolean> booleanValues = Arrays.asList(true, false, true, true, false);
+        builder.setProperty("booleanArray", booleanValues, Type.BOOLEANS);
+
+        // Double properties
+        builder.setProperty("doubleValue", 123.456);
+        builder.setProperty("doubleZero", 0.0);
+        builder.setProperty("doubleNegative", -987.654);
+        List<Double> doubleValues = Arrays.asList(1.1, 2.2, 3.3, -4.4, 0.0);
+        builder.setProperty("doubleArray", doubleValues, Type.DOUBLES);
+
+        // Long properties
+        builder.setProperty("longValue", 1234567890L);
+        builder.setProperty("longZero", 0L);
+        builder.setProperty("longNegative", -9876543210L);
+        builder.setProperty("longMax", Long.MAX_VALUE);
+        builder.setProperty("longMin", Long.MIN_VALUE);
+        List<Long> longValues = Arrays.asList(11L, 22L, 33L, -44L, 0L);
+        builder.setProperty("longArray", longValues, Type.LONGS);
+
+        // String array
+        List<String> mixedStrings = Arrays.asList("string", "123", "true");
+        builder.setProperty("mixedArray", mixedStrings, Type.STRINGS);
+
+        Map<String, Object> result = JsonUtils.convertNodeStateToMap(builder.getNodeState(), -1, true);
+
+        // Check boolean values
+        assertPropertyValue(result, "boolTrue", true);
+        assertPropertyValue(result, "boolFalse", false);
+        assertArrayValues(result, "booleanArray", booleanValues);
+
+        // Check double values
+        assertEquals(123.456, (Double) result.get("doubleValue"), 0.0001);
+        assertEquals(0.0, (Double) result.get("doubleZero"), 0.0001);
+        assertEquals(-987.654, (Double) result.get("doubleNegative"), 0.0001);
+
+        List<Double> doubleResultArray = (List<Double>) result.get("doubleArray");
+        for (int i = 0; i < doubleValues.size(); i++) {
+            assertEquals(doubleValues.get(i), doubleResultArray.get(i), 0.0001);
+        }
+
+        // Check long values
+        assertPropertyValue(result, "longValue", 1234567890L);
+        assertPropertyValue(result, "longZero", 0L);
+        assertPropertyValue(result, "longNegative", -9876543210L);
+        assertPropertyValue(result, "longMax", Long.MAX_VALUE);
+        assertPropertyValue(result, "longMin", Long.MIN_VALUE);
+        assertArrayValues(result, "longArray", longValues);
+
+        // Check string array
+        assertArrayValues(result, "mixedArray", mixedStrings);
+    }
+
+    @Test
+    public void testNodeStateToJson() {
+        try {
+            NodeBuilder builder = createNodeBuilder();
+
+            // Add various types of properties
+            builder.setProperty("string", "string value");
+            builder.setProperty("long", 12345L);
+            builder.setProperty("double", 123.456);
+            builder.setProperty("boolean", true);
+            builder.setProperty("decimal", new BigDecimal("987.654"));
+
+            // Add array properties
+            builder.setProperty("stringArray", Arrays.asList("value1", "value2", "value3"), Type.STRINGS);
+            builder.setProperty("longArray", Arrays.asList(1L, 2L, 3L), Type.LONGS);
+            builder.setProperty("doubleArray", Arrays.asList(1.1, 2.2, 3.3), Type.DOUBLES);
+
+            // Add a child node
+            NodeBuilder child = builder.child("child");
+            child.setProperty("childProp", "child value");
+
+            NodeState nodeState = builder.getNodeState();
+
+            String json = JsonUtils.nodeStateToJson(nodeState, -1);
+
+            // Basic verification that the JSON contains expected values
+            assertTrue(json.contains("\"string\" : \"string value\""));
+            assertTrue(json.contains("\"long\" : 12345"));
+            assertTrue(json.contains("\"double\" : 123.456"));
+            assertTrue(json.contains("\"boolean\" : true"));
+
+            // Verify array format
+            assertTrue(json.contains("\"stringArray\" : [ \"value1\", \"value2\", \"value3\" ]"));
+            assertTrue(json.contains("\"longArray\" : [ 1, 2, 3 ]"));
+
+            // Verify child node
+            assertTrue(json.contains("\"child\" : {"));
+            assertTrue(json.contains("\"childProp\" : \"child value\""));
+
+            // Test with limited depth
+            String jsonLimitedDepth = JsonUtils.nodeStateToJson(nodeState, 0);
+            assertTrue(jsonLimitedDepth.contains("\"string\" : \"string value\""));
+            assertFalse(jsonLimitedDepth.contains("\"childProp\" : \"child value\""));
+
+        } catch (Exception e) {
+            fail("Exception thrown during test: " + e.getMessage());
+        }
     }
 }