jerryshao commented on code in PR #12499:
URL: https://github.com/apache/gravitino/pull/12499#discussion_r3841299687


##########
api/src/main/java/org/apache/gravitino/semantic/SemanticModelDefinition.java:
##########
@@ -0,0 +1,280 @@
+/*
+ * 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.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.annotation.Evolving;
+
+/**
+ * An immutable Semantic Model definition. This value groups the 
Ossie-compatible definition fields
+ * used when creating or replacing a Semantic Model and has no name or 
independent lifecycle.
+ */
+@Evolving
+public final class SemanticModelDefinition {
+
+  private final Dataset[] datasets;
+
+  @Nullable private final AIContext aiContext;
+  @Nullable private final Relationship[] relationships;
+  @Nullable private final Metric[] metrics;
+  @Nullable private final CustomExtension[] customExtensions;
+
+  private SemanticModelDefinition(Builder builder) {
+    this.aiContext = builder.aiContext;
+    this.datasets = Arrays.copyOf(builder.datasets, builder.datasets.length);
+    this.relationships =
+        builder.relationships == null
+            ? null
+            : Arrays.copyOf(builder.relationships, 
builder.relationships.length);
+    this.metrics =
+        builder.metrics == null ? null : Arrays.copyOf(builder.metrics, 
builder.metrics.length);
+    this.customExtensions =
+        builder.customExtensions == null
+            ? null
+            : Arrays.copyOf(builder.customExtensions, 
builder.customExtensions.length);
+  }
+
+  /**
+   * Creates a builder for an immutable {@link SemanticModelDefinition}.
+   *
+   * @return A new builder.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * Returns the AI context associated with the Semantic Model definition.
+   *
+   * @return The AI context, or {@code null} if it is not set.
+   */
+  @Nullable
+  public AIContext aiContext() {
+    return aiContext;
+  }
+
+  /**
+   * Returns a copy of the datasets in the Semantic Model definition.
+   *
+   * @return The non-empty dataset array.
+   */
+  public Dataset[] datasets() {
+    return Arrays.copyOf(datasets, datasets.length);
+  }
+
+  /**
+   * Returns a copy of the relationships in the Semantic Model definition.
+   *
+   * @return The relationships, or {@code null} if they are not set.
+   */
+  @Nullable
+  public Relationship[] relationships() {
+    return relationships == null ? null : Arrays.copyOf(relationships, 
relationships.length);
+  }
+
+  /**
+   * Returns a copy of the metrics in the Semantic Model definition.
+   *
+   * @return The metrics, or {@code null} if they are not set.
+   */
+  @Nullable
+  public Metric[] metrics() {
+    return metrics == null ? null : Arrays.copyOf(metrics, metrics.length);
+  }
+
+  /**
+   * Returns a copy of the custom extensions in the Semantic Model definition.
+   *
+   * @return The custom extensions, or {@code null} if they are not set.
+   */
+  @Nullable
+  public CustomExtension[] customExtensions() {
+    return customExtensions == null
+        ? null
+        : Arrays.copyOf(customExtensions, customExtensions.length);
+  }
+
+  /**
+   * Compares this definition with another object for value equality.
+   *
+   * @param other The object to compare with.
+   * @return {@code true} if the objects are equal, otherwise {@code false}.
+   */
+  @Override
+  public boolean equals(@Nullable Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof SemanticModelDefinition)) {
+      return false;
+    }
+    SemanticModelDefinition that = (SemanticModelDefinition) other;
+    return Objects.equals(aiContext, that.aiContext)
+        && Arrays.equals(datasets, that.datasets)
+        && Arrays.equals(relationships, that.relationships)
+        && Arrays.equals(metrics, that.metrics)
+        && Arrays.equals(customExtensions, that.customExtensions);
+  }
+
+  /**
+   * Returns the value-based hash code for this definition.
+   *
+   * @return The hash code.
+   */
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        aiContext,
+        Arrays.hashCode(datasets),
+        Arrays.hashCode(relationships),
+        Arrays.hashCode(metrics),
+        Arrays.hashCode(customExtensions));
+  }
+
+  /**
+   * Returns a string representation of this definition.
+   *
+   * @return The string representation.
+   */
+  @Override
+  public String toString() {
+    return "SemanticModelDefinition{"
+        + "aiContext="
+        + aiContext
+        + ", datasets="
+        + Arrays.toString(datasets)
+        + ", relationships="
+        + Arrays.toString(relationships)
+        + ", metrics="
+        + Arrays.toString(metrics)
+        + ", customExtensions="
+        + Arrays.toString(customExtensions)
+        + '}';
+  }
+
+  static void validateNoNullElements(String name, @Nullable Object[] values) {
+    if (values == null) {
+      return;
+    }
+    for (int index = 0; index < values.length; index++) {
+      Preconditions.checkArgument(values[index] != null, "%s[%s] must not be 
null", name, index);
+    }
+  }
+
+  static void validateNonEmptyStringElements(String name, @Nullable String[] 
values) {
+    if (values == null) {
+      return;
+    }
+    for (int index = 0; index < values.length; index++) {
+      Preconditions.checkArgument(
+          values[index] != null && !values[index].isEmpty(),
+          "%s[%s] must not be null or empty",
+          name,
+          index);
+    }
+  }
+
+  /** A builder for immutable {@link SemanticModelDefinition} values. */
+  public static final class Builder {
+
+    private Dataset[] datasets;
+
+    @Nullable private AIContext aiContext;
+    @Nullable private Relationship[] relationships;
+    @Nullable private Metric[] metrics;
+    @Nullable private CustomExtension[] customExtensions;
+
+    private Builder() {}
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context, or {@code null} to leave it unset.
+     * @return This builder.
+     */
+    public Builder withAIContext(@Nullable AIContext aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+
+    /**
+     * Sets the datasets in the Semantic Model definition.
+     *
+     * @param datasets The non-empty dataset array.
+     * @return This builder.
+     */
+    public Builder withDatasets(Dataset[] datasets) {
+      this.datasets = datasets;
+      return this;
+    }
+
+    /**
+     * Sets the optional relationships.
+     *
+     * @param relationships The relationships, or {@code null} to leave them 
unset.
+     * @return This builder.
+     */
+    public Builder withRelationships(@Nullable Relationship[] relationships) {
+      this.relationships = relationships;
+      return this;
+    }
+
+    /**
+     * Sets the optional metrics.
+     *
+     * @param metrics The metrics, or {@code null} to leave them unset.
+     * @return This builder.
+     */
+    public Builder withMetrics(@Nullable Metric[] metrics) {
+      this.metrics = metrics;
+      return this;
+    }
+
+    /**
+     * Sets the optional custom extensions.
+     *
+     * @param customExtensions The custom extensions, or {@code null} to leave 
them unset.
+     * @return This builder.
+     */
+    public Builder withCustomExtensions(@Nullable CustomExtension[] 
customExtensions) {
+      this.customExtensions = customExtensions;
+      return this;
+    }
+
+    /**
+     * Builds an immutable {@link SemanticModelDefinition}.
+     *
+     * @return The new definition.
+     * @throws IllegalArgumentException If the dataset array is null or empty, 
or any definition
+     *     array contains null.
+     */
+    public SemanticModelDefinition build() {

Review Comment:
   **[Confirmed]** `Builder.build()` never checks dataset/relationship/metric 
name uniqueness, contradicting the design doc's stated contract.
   
   `SemanticModelDefinition.builder().withDatasets(new Dataset[]{orders, orders 
/* duplicate name */}).build()` succeeds silently instead of throwing 
`IllegalArgumentException`, even though 
`design-docs/gravitino-semantic-model-design.md` states "Dataset names are 
unique within a Semantic Model" and "Relationship and Metric names are unique 
within a Semantic Model" as flat, always-true facts. The sibling check 
(`Expression.builder()` rejecting duplicate dialects) shows this kind of 
structural, catalog-independent validation is expected at this layer.



##########
api/src/main/java/org/apache/gravitino/semantic/Dataset.java:
##########
@@ -0,0 +1,375 @@
+/*
+ * 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.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.Evolving;
+
+/**
+ * An immutable dataset in a semantic model. A dataset binds a semantic name 
and optional semantic
+ * metadata to a governed Gravitino table or logical view identified by {@link 
NameIdentifier}.
+ */
+@Evolving
+public final class Dataset {
+
+  private final String name;
+  private final NameIdentifier source;
+
+  @Nullable private final String[] primaryKey;
+  @Nullable private final String[][] uniqueKeys;
+  @Nullable private final String description;
+  @Nullable private final AIContext aiContext;
+  @Nullable private final Field[] fields;
+  @Nullable private final CustomExtension[] customExtensions;
+
+  private Dataset(Builder builder) {
+    this.name = builder.name;
+    this.source = builder.source;
+    this.primaryKey = copyOrNull(builder.primaryKey);
+    this.uniqueKeys = copyUniqueKeys(builder.uniqueKeys);
+    this.description = builder.description;
+    this.aiContext = builder.aiContext;
+    this.fields =
+        builder.fields == null ? null : Arrays.copyOf(builder.fields, 
builder.fields.length);
+    this.customExtensions =
+        builder.customExtensions == null
+            ? null
+            : Arrays.copyOf(builder.customExtensions, 
builder.customExtensions.length);
+  }
+
+  /**
+   * Creates a builder for an immutable {@link Dataset}.
+   *
+   * @return A new builder.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * Returns the dataset name.
+   *
+   * @return The dataset name.
+   */
+  public String name() {
+    return name;
+  }
+
+  /**
+   * Returns the governed table or logical view that backs this dataset.
+   *
+   * @return The source identifier.
+   */
+  public NameIdentifier source() {
+    return source;
+  }
+
+  /**
+   * Returns a copy of the primary key columns.
+   *
+   * @return The primary key columns, or {@code null} if they are not set.
+   */
+  @Nullable
+  public String[] primaryKey() {
+    return copyOrNull(primaryKey);
+  }
+
+  /**
+   * Returns a deep copy of the unique key definitions.
+   *
+   * @return The unique keys, or {@code null} if they are not set.
+   */
+  @Nullable
+  public String[][] uniqueKeys() {
+    return copyUniqueKeys(uniqueKeys);
+  }
+
+  /**
+   * Returns the dataset description.
+   *
+   * @return The dataset description, or {@code null} if it is not set.
+   */
+  @Nullable
+  public String description() {
+    return description;
+  }
+
+  /**
+   * Returns the AI context associated with the dataset.
+   *
+   * @return The AI context, or {@code null} if it is not set.
+   */
+  @Nullable
+  public AIContext aiContext() {
+    return aiContext;
+  }
+
+  /**
+   * Returns a copy of the fields defined by the dataset.
+   *
+   * @return The fields, or {@code null} if they are not set.
+   */
+  @Nullable
+  public Field[] fields() {
+    return fields == null ? null : Arrays.copyOf(fields, fields.length);
+  }
+
+  /**
+   * Returns a copy of the custom extensions associated with the dataset.
+   *
+   * @return The custom extensions, or {@code null} if they are not set.
+   */
+  @Nullable
+  public CustomExtension[] customExtensions() {
+    return customExtensions == null
+        ? null
+        : Arrays.copyOf(customExtensions, customExtensions.length);
+  }
+
+  /**
+   * Compares this dataset with another object for value equality.
+   *
+   * @param other The object to compare with.
+   * @return {@code true} if the objects are equal, otherwise {@code false}.
+   */
+  @Override
+  public boolean equals(@Nullable Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof Dataset)) {
+      return false;
+    }
+    Dataset that = (Dataset) other;
+    return name.equals(that.name)
+        && source.equals(that.source)
+        && Arrays.equals(primaryKey, that.primaryKey)
+        && Arrays.deepEquals(uniqueKeys, that.uniqueKeys)
+        && Objects.equals(description, that.description)
+        && Objects.equals(aiContext, that.aiContext)
+        && Arrays.equals(fields, that.fields)
+        && Arrays.equals(customExtensions, that.customExtensions);
+  }
+
+  /**
+   * Returns the value-based hash code for this dataset.
+   *
+   * @return The hash code.
+   */
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        name,
+        source,
+        Arrays.hashCode(primaryKey),
+        Arrays.deepHashCode(uniqueKeys),
+        description,
+        aiContext,
+        Arrays.hashCode(fields),
+        Arrays.hashCode(customExtensions));
+  }
+
+  /**
+   * Returns a string representation of this dataset.
+   *
+   * @return The string representation.
+   */
+  @Override
+  public String toString() {
+    return "Dataset{"
+        + "name='"
+        + name
+        + '\''
+        + ", source="
+        + source
+        + ", primaryKey="
+        + Arrays.toString(primaryKey)
+        + ", uniqueKeys="
+        + Arrays.deepToString(uniqueKeys)
+        + ", description='"
+        + description
+        + '\''
+        + ", aiContext="
+        + aiContext
+        + ", fields="
+        + Arrays.toString(fields)
+        + ", customExtensions="
+        + Arrays.toString(customExtensions)
+        + '}';
+  }
+
+  /** A builder for immutable {@link Dataset} values. */
+  public static final class Builder {
+
+    private String name;
+    private NameIdentifier source;
+
+    @Nullable private String[] primaryKey;
+    @Nullable private String[][] uniqueKeys;
+    @Nullable private String description;
+    @Nullable private AIContext aiContext;
+    @Nullable private Field[] fields;
+    @Nullable private CustomExtension[] customExtensions;
+
+    private Builder() {}
+
+    /**
+     * Sets the dataset name.
+     *
+     * @param name The non-empty dataset name.
+     * @return This builder.
+     */
+    public Builder withName(String name) {
+      this.name = name;
+      return this;
+    }
+
+    /**
+     * Sets the governed table or logical view that backs this dataset.
+     *
+     * @param source The source identifier.
+     * @return This builder.
+     */
+    public Builder withSource(NameIdentifier source) {
+      this.source = source;
+      return this;
+    }
+
+    /**
+     * Sets the optional primary key columns.
+     *
+     * @param primaryKey The primary key columns, or {@code null} to leave 
them unset.
+     * @return This builder.
+     */
+    public Builder withPrimaryKey(@Nullable String[] primaryKey) {
+      this.primaryKey = primaryKey;
+      return this;
+    }
+
+    /**
+     * Sets the optional unique key definitions.
+     *
+     * @param uniqueKeys The unique keys, or {@code null} to leave them unset.
+     * @return This builder.
+     */
+    public Builder withUniqueKeys(@Nullable String[][] uniqueKeys) {
+      this.uniqueKeys = uniqueKeys;
+      return this;
+    }
+
+    /**
+     * Sets the optional dataset description.
+     *
+     * @param description The description, or {@code null} to leave it unset.
+     * @return This builder.
+     */
+    public Builder withDescription(@Nullable String description) {
+      this.description = description;
+      return this;
+    }
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context, or {@code null} to leave it unset.
+     * @return This builder.
+     */
+    public Builder withAIContext(@Nullable AIContext aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+
+    /**
+     * Sets the optional semantic fields.
+     *
+     * @param fields The fields, or {@code null} to leave them unset.
+     * @return This builder.
+     */
+    public Builder withFields(@Nullable Field[] fields) {
+      this.fields = fields;
+      return this;
+    }
+
+    /**
+     * Sets the optional custom extensions.
+     *
+     * @param customExtensions The custom extensions, or {@code null} to leave 
them unset.
+     * @return This builder.
+     */
+    public Builder withCustomExtensions(@Nullable CustomExtension[] 
customExtensions) {
+      this.customExtensions = customExtensions;
+      return this;
+    }
+
+    /**
+     * Builds an immutable {@link Dataset}.
+     *
+     * @return The new dataset.
+     * @throws IllegalArgumentException If the name is null or empty, the 
source is null, or an
+     *     optional array contains an invalid element.
+     */
+    public Dataset build() {

Review Comment:
   **[Confirmed]** `Builder.build()` never checks that its `fields` array has 
unique names, and no code cross-checks that a `Relationship`'s `from`/`to` 
names an actual `Dataset` in the same definition.
   
   A `Dataset` built with two `Field` entries both named `"amount"`, or a 
`Relationship` whose `from()`/`to()` name a dataset that doesn't exist in the 
`SemanticModelDefinition`'s datasets array, both build successfully with no 
exception — even though the design doc states these as required invariants 
(field names unique within each Dataset; each relationship endpoint names a 
Dataset in the same Semantic Model). No test in `TestSemanticModelMembers.java` 
or `TestSemanticModelSupportingTypes.java` exercises either case.



##########
api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java:
##########
@@ -0,0 +1,376 @@
+/*
+ * 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.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.annotation.Evolving;
+
+/** Structured AI context with optional standard fields and retained custom 
JSON properties. */
+@Evolving
+public final class AIContextObject {
+
+  @Nullable private final String instructions;
+  @Nullable private final String[] synonyms;
+  @Nullable private final String[] examples;
+  private final Map<String, Object> additionalProperties;
+
+  private AIContextObject(Builder builder) {
+    this.instructions = builder.instructions;
+    this.synonyms = copyOrNull(builder.synonyms);
+    this.examples = copyOrNull(builder.examples);
+    this.additionalProperties = 
immutableAdditionalProperties(builder.additionalProperties);
+  }
+
+  /**
+   * Creates a builder for structured AI context.
+   *
+   * @return A new builder.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * Returns instructions for AI tools.
+   *
+   * @return The instructions, or null when not provided.
+   */
+  @Nullable
+  public String instructions() {
+    return instructions;
+  }
+
+  /**
+   * Returns alternative names and terms.
+   *
+   * @return A defensive copy of the synonyms, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] synonyms() {
+    return copyOrNull(synonyms);
+  }
+
+  /**
+   * Returns sample questions or use cases.
+   *
+   * @return A defensive copy of the examples, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] examples() {
+    return copyOrNull(examples);
+  }
+
+  /**
+   * Returns custom JSON-compatible properties not represented by the standard 
fields.
+   *
+   * <p>The returned map and every nested map or JSON array are unmodifiable. 
Java arrays supplied
+   * to the builder are represented as unmodifiable lists. Integral numbers 
are represented as
+   * {@link BigInteger}, and decimal numbers are represented as {@link 
BigDecimal}.
+   *
+   * @return The deeply immutable additional properties, preserving iteration 
order.
+   */
+  public Map<String, Object> additionalProperties() {
+    return additionalProperties;
+  }
+
+  /**
+   * Compares this structured AI context with another object.
+   *
+   * @param other The object to compare.
+   * @return {@code true} if the object has the same standard and additional 
properties.
+   */
+  @Override
+  public boolean equals(@Nullable Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof AIContextObject)) {
+      return false;
+    }
+    AIContextObject that = (AIContextObject) other;
+    return Objects.equals(instructions, that.instructions)
+        && Arrays.equals(synonyms, that.synonyms)
+        && Arrays.equals(examples, that.examples)
+        && additionalProperties.equals(that.additionalProperties);
+  }
+
+  /**
+   * Returns the hash code of this structured AI context.
+   *
+   * @return The hash code.
+   */
+  @Override
+  public int hashCode() {
+    int result = Objects.hash(instructions, additionalProperties);
+    result = 31 * result + Arrays.hashCode(synonyms);
+    result = 31 * result + Arrays.hashCode(examples);
+    return result;
+  }
+
+  /**
+   * Returns a string representation of this structured AI context.
+   *
+   * @return The string representation.
+   */
+  @Override
+  public String toString() {
+    return "AIContextObject{"
+        + "instructions='"
+        + instructions
+        + '\''
+        + ", synonyms="
+        + Arrays.toString(synonyms)
+        + ", examples="
+        + Arrays.toString(examples)
+        + ", additionalProperties="
+        + additionalProperties
+        + '}';
+  }
+
+  /** A builder for {@link AIContextObject}. */
+  public static final class Builder {
+
+    @Nullable private String instructions;
+    @Nullable private String[] synonyms;
+    @Nullable private String[] examples;
+    private Map<String, Object> additionalProperties = Collections.emptyMap();
+
+    private Builder() {}
+
+    /**
+     * Sets or clears instructions for AI tools.
+     *
+     * @param instructions The instructions, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withInstructions(@Nullable String instructions) {
+      this.instructions = instructions;
+      return this;
+    }
+
+    /**
+     * Sets or clears alternative names and terms.
+     *
+     * @param synonyms The synonyms, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withSynonyms(@Nullable String[] synonyms) {
+      this.synonyms = synonyms;
+      return this;
+    }
+
+    /**
+     * Sets or clears sample questions or use cases.
+     *
+     * @param examples The examples, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withExamples(@Nullable String[] examples) {
+      this.examples = examples;
+      return this;
+    }
+
+    /**
+     * Sets additional JSON-compatible properties.
+     *
+     * <p>Values may be null, strings, booleans, JSON-compatible numbers, maps 
with string keys,
+     * lists, or Java arrays. Integral numbers are normalized to {@link 
BigInteger}, and decimal
+     * numbers are normalized to {@link BigDecimal} so their value semantics 
remain stable across
+     * JSON round trips. Property names must not duplicate {@code 
instructions}, {@code synonyms},
+     * or {@code examples}.
+     *
+     * @param additionalProperties The additional properties.
+     * @return This builder.
+     */
+    public Builder withAdditionalProperties(Map<String, Object> 
additionalProperties) {
+      this.additionalProperties = additionalProperties;
+      return this;
+    }
+
+    /**
+     * Builds structured AI context.
+     *
+     * @return The immutable structured AI context.
+     * @throws IllegalArgumentException If a string array contains null, the 
additional properties
+     *     are null, a property duplicates a standard field, or a property 
value is not
+     *     JSON-compatible.
+     */
+    public AIContextObject build() {
+      SemanticModelDefinition.validateNoNullElements("synonyms", synonyms);
+      SemanticModelDefinition.validateNoNullElements("examples", examples);
+      Preconditions.checkArgument(
+          additionalProperties != null, "additionalProperties must not be 
null");
+      return new AIContextObject(this);
+    }
+  }
+
+  @Nullable
+  private static String[] copyOrNull(@Nullable String[] values) {

Review Comment:
   **[Plausible]** `copyOrNull(String[])` is duplicated verbatim between 
`AIContextObject` and `Dataset`, and the `x == null ? null : Arrays.copyOf(x, 
x.length)` array-defensive-copy pattern for `CustomExtension[]` is repeated 
inline across `Dataset`, `Field`, `Metric`, `Relationship`, and 
`SemanticModelDefinition` (~10 call sites).
   
   A future fix to the copy semantics (e.g. how a zero-length array is treated) 
requires coordinated edits across 5+ files instead of one shared helper; every 
new array-typed field in this package re-copy-pastes the same snippet.



##########
api/src/main/java/org/apache/gravitino/semantic/AIContextObject.java:
##########
@@ -0,0 +1,376 @@
+/*
+ * 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.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.annotation.Evolving;
+
+/** Structured AI context with optional standard fields and retained custom 
JSON properties. */
+@Evolving
+public final class AIContextObject {
+
+  @Nullable private final String instructions;
+  @Nullable private final String[] synonyms;
+  @Nullable private final String[] examples;
+  private final Map<String, Object> additionalProperties;
+
+  private AIContextObject(Builder builder) {
+    this.instructions = builder.instructions;
+    this.synonyms = copyOrNull(builder.synonyms);
+    this.examples = copyOrNull(builder.examples);
+    this.additionalProperties = 
immutableAdditionalProperties(builder.additionalProperties);
+  }
+
+  /**
+   * Creates a builder for structured AI context.
+   *
+   * @return A new builder.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * Returns instructions for AI tools.
+   *
+   * @return The instructions, or null when not provided.
+   */
+  @Nullable
+  public String instructions() {
+    return instructions;
+  }
+
+  /**
+   * Returns alternative names and terms.
+   *
+   * @return A defensive copy of the synonyms, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] synonyms() {
+    return copyOrNull(synonyms);
+  }
+
+  /**
+   * Returns sample questions or use cases.
+   *
+   * @return A defensive copy of the examples, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] examples() {
+    return copyOrNull(examples);
+  }
+
+  /**
+   * Returns custom JSON-compatible properties not represented by the standard 
fields.
+   *
+   * <p>The returned map and every nested map or JSON array are unmodifiable. 
Java arrays supplied
+   * to the builder are represented as unmodifiable lists. Integral numbers 
are represented as
+   * {@link BigInteger}, and decimal numbers are represented as {@link 
BigDecimal}.
+   *
+   * @return The deeply immutable additional properties, preserving iteration 
order.
+   */
+  public Map<String, Object> additionalProperties() {
+    return additionalProperties;
+  }
+
+  /**
+   * Compares this structured AI context with another object.
+   *
+   * @param other The object to compare.
+   * @return {@code true} if the object has the same standard and additional 
properties.
+   */
+  @Override
+  public boolean equals(@Nullable Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (!(other instanceof AIContextObject)) {
+      return false;
+    }
+    AIContextObject that = (AIContextObject) other;
+    return Objects.equals(instructions, that.instructions)
+        && Arrays.equals(synonyms, that.synonyms)
+        && Arrays.equals(examples, that.examples)
+        && additionalProperties.equals(that.additionalProperties);
+  }
+
+  /**
+   * Returns the hash code of this structured AI context.
+   *
+   * @return The hash code.
+   */
+  @Override
+  public int hashCode() {
+    int result = Objects.hash(instructions, additionalProperties);
+    result = 31 * result + Arrays.hashCode(synonyms);
+    result = 31 * result + Arrays.hashCode(examples);
+    return result;
+  }
+
+  /**
+   * Returns a string representation of this structured AI context.
+   *
+   * @return The string representation.
+   */
+  @Override
+  public String toString() {
+    return "AIContextObject{"
+        + "instructions='"
+        + instructions
+        + '\''
+        + ", synonyms="
+        + Arrays.toString(synonyms)
+        + ", examples="
+        + Arrays.toString(examples)
+        + ", additionalProperties="
+        + additionalProperties
+        + '}';
+  }
+
+  /** A builder for {@link AIContextObject}. */
+  public static final class Builder {
+
+    @Nullable private String instructions;
+    @Nullable private String[] synonyms;
+    @Nullable private String[] examples;
+    private Map<String, Object> additionalProperties = Collections.emptyMap();
+
+    private Builder() {}
+
+    /**
+     * Sets or clears instructions for AI tools.
+     *
+     * @param instructions The instructions, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withInstructions(@Nullable String instructions) {
+      this.instructions = instructions;
+      return this;
+    }
+
+    /**
+     * Sets or clears alternative names and terms.
+     *
+     * @param synonyms The synonyms, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withSynonyms(@Nullable String[] synonyms) {
+      this.synonyms = synonyms;
+      return this;
+    }
+
+    /**
+     * Sets or clears sample questions or use cases.
+     *
+     * @param examples The examples, or null to leave them unset.
+     * @return This builder.
+     */
+    public Builder withExamples(@Nullable String[] examples) {
+      this.examples = examples;
+      return this;
+    }
+
+    /**
+     * Sets additional JSON-compatible properties.
+     *
+     * <p>Values may be null, strings, booleans, JSON-compatible numbers, maps 
with string keys,
+     * lists, or Java arrays. Integral numbers are normalized to {@link 
BigInteger}, and decimal
+     * numbers are normalized to {@link BigDecimal} so their value semantics 
remain stable across
+     * JSON round trips. Property names must not duplicate {@code 
instructions}, {@code synonyms},
+     * or {@code examples}.
+     *
+     * @param additionalProperties The additional properties.
+     * @return This builder.
+     */
+    public Builder withAdditionalProperties(Map<String, Object> 
additionalProperties) {
+      this.additionalProperties = additionalProperties;
+      return this;
+    }
+
+    /**
+     * Builds structured AI context.
+     *
+     * @return The immutable structured AI context.
+     * @throws IllegalArgumentException If a string array contains null, the 
additional properties
+     *     are null, a property duplicates a standard field, or a property 
value is not
+     *     JSON-compatible.
+     */
+    public AIContextObject build() {
+      SemanticModelDefinition.validateNoNullElements("synonyms", synonyms);
+      SemanticModelDefinition.validateNoNullElements("examples", examples);
+      Preconditions.checkArgument(
+          additionalProperties != null, "additionalProperties must not be 
null");
+      return new AIContextObject(this);
+    }
+  }
+
+  @Nullable
+  private static String[] copyOrNull(@Nullable String[] values) {

Review Comment:
   **[Plausible]** ~140 lines of hand-rolled deep-immutability + 
cycle-detection + JSON-number-canonicalization logic 
(`immutableAdditionalProperties`/`immutableJsonValue`/`immutableJsonMap`/`immutableJsonList`/`immutableJsonArray`/`canonicalizeJsonNumber`/`enterContainer`)
 exist to support one `Map<String,Object>` field, duplicating what a JSON 
library already provides.
   
   This is a special-case bandaid on a single field rather than a reusable 
mechanism: the sibling "vendor JSON blob" need in this same PR 
(`CustomExtension.data`) is solved completely differently, as an opaque 
unvalidated `String`. The next class needing an arbitrary immutable JSON value 
must either copy-paste this 140-line engine again or fall back to the 
untyped-`String` pattern, and untested edges (very deep nesting causing 
`StackOverflowError`, unusual `Number` subclasses) have no coverage beyond what 
`TestSemanticModelSupportingTypes` exercises.



##########
api/src/main/java/org/apache/gravitino/semantic/SemanticModelChange.java:
##########
@@ -0,0 +1,326 @@
+/*
+ * 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.gravitino.semantic;
+
+import com.google.common.base.Preconditions;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.annotation.Evolving;
+
+/**
+ * A change that can be applied to a Semantic Model through {@link
+ * SemanticModelCatalog#alterSemanticModel(org.apache.gravitino.NameIdentifier,

Review Comment:
   **[Plausible / style]** This Javadoc uses the fully-qualified name 
`org.apache.gravitino.NameIdentifier` inside `{@link}` instead of importing it, 
which the project's CLAUDE.md explicitly disallows ("Always use normal import 
statements instead of Fully Qualified Class Names (FQN)... unless there is a 
real class name conflict").
   
   There's no `import org.apache.gravitino.NameIdentifier;` in this file and no 
naming conflict (it isn't used anywhere else in the file), so this FQN in the 
`{@link 
SemanticModelCatalog#alterSemanticModel(org.apache.gravitino.NameIdentifier, 
SemanticModelChange...)}` tag is an avoidable, checkstyle-visible style 
violation.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to