andygrove commented on code in PR #5365:
URL: https://github.com/apache/datafusion-comet/pull/5365#discussion_r3917353053


##########
native/proto/src/proto/operator.proto:
##########
@@ -77,6 +77,8 @@ message Operator {
     // zero runtime cost.
     ContribScan contrib_scan = 200;
   }
+
+  reserved "delta_scan";

Review Comment:
   `delta_scan` was never on `main`. Field 119 is and always was 
`window_group_limit`, and `delta_scan` only existed in earlier revisions of 
this branch. Since this reserves the name rather than a field number it does 
not buy wire compatibility either, and it reads as though a released field was 
removed. Could it just be dropped?



##########
contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/JvmLowercaseParitySuite.scala:
##########
@@ -0,0 +1,343 @@
+/*
+ * 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.comet.contrib.delta
+
+import java.util.Locale
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.comet.serde.operator.JvmCaseTables
+
+/**
+ * Self-validating proof that `JvmCaseTables.mirrorLowercase` -- a 
line-for-line mirror of the
+ * native `JvmCaseTables::lowercase` in 
`native/core/src/parquet/schema_adapter.rs`, run over the
+ * tables `JvmCaseTables` generates from the RUNNING JVM -- reproduces this 
JVM's
+ * `String.toLowerCase(Locale.ROOT)`. Because both the tables and the 
expectations come from the
+ * same running JVM, this suite passes on ANY supported JDK by construction.
+ *
+ * Needs no `SparkSession`, so it extends `AnyFunSuite` directly rather than 
`CometDeltaTestBase`.
+ */
+class JvmLowercaseParitySuite extends AnyFunSuite {

Review Comment:
   This suite proves that `JvmCaseTables.mirrorLowercase` equals 
`String.toLowerCase(Locale.ROOT)`, but `mirrorLowercase` is not the code that 
runs. The Rust `JvmCaseTables::lowercase` is, and its only coverage is the 
hand-written 27-range `jdk17_test_tables` in `schema_adapter.rs`. Two 
hand-written ports of an algorithm this intricate will drift eventually, and 
the drift would surface as silently wrong column matching rather than a failure.
   
   I checked the Rust side directly and it passes cleanly, as described in my 
top-level comment, so this is not a bug report. It is that the check does not 
exist in the repo. Could it live here? Writing the JVM-generated tables plus 
the expected outputs to a test resource and reading them from a Rust test would 
verify the real implementation and would make the 539-line Scala mirror 
unnecessary.
   
   Separately, since what this validates is core behavior rather than Delta 
behavior, could the suite move to `spark/src/test`? The `delta` filter in 
`compute-changes.py` does include `spark/src/main/**` and `native/**/src/**`, 
so it fires today, but anyone building or testing Comet without `-Pdelta` never 
runs it, and if the Delta contrib is ever renamed or dropped the verification 
goes with it.



##########
spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala:
##########
@@ -125,166 +150,233 @@ object CometNativeScan extends 
CometOperatorSerde[CometScanExec] with CometTypeS
       scan: CometScanExec,
       builder: Operator.Builder,
       childOp: OperatorOuterClass.Operator*): 
Option[OperatorOuterClass.Operator] = {
-    val nativeScanBuilder = OperatorOuterClass.NativeScan.newBuilder()
+    // Extract object store options from first file (S3 configs apply to all 
files in scan).
+    // Use selectedPartitions (static) instead of getFilePartitions() because 
at planning time
+    // DPP subqueries haven't been resolved yet. Object store options don't 
depend on DPP.
+    val firstFileUri = scan.selectedPartitions
+      .flatMap(_.files.headOption)
+      .headOption
+      .map(_.getPath.toUri)
+
+    // Collect S3/cloud storage configurations
+    val hadoopConf = scan.relation.sparkSession.sessionState
+      .newHadoopConfWithOptions(scan.relation.options)
+
+    buildNativeScanCommon(
+      source = scan.simpleStringWithNodeId(),
+      output = scan.output,
+      requiredSchema = scan.requiredSchema,
+      dataSchema = scan.relation.dataSchema,
+      partitionSchema = scan.relation.partitionSchema,
+      fileConstantMetadataColumns = scan.wrapped.fileConstantMetadataColumns,
+      dataFilters = scan.supportedDataFilters,
+      firstFileUri = firstFileUri,
+      hadoopConf = hadoopConf,
+      conf = scan.conf) match {
+      case Some(commonBuilder) =>
+        // Sink operators don't have children
+        builder.clearChildren()
+        val nativeScanBuilder = OperatorOuterClass.NativeScan.newBuilder()
+        // Set common data in NativeScan (file_partition will be populated at 
execution time)
+        nativeScanBuilder.setCommon(commonBuilder.build())
+        Some(builder.setNativeScan(nativeScanBuilder).build())
+      case None =>
+        // There are unsupported scan type
+        withFallbackReason(
+          scan,
+          s"unsupported Comet operator: ${scan.nodeName}, due to unsupported 
data types above")
+        None
+    }
+  }
+
+  /**
+   * Build the `NativeScanCommon` proto shared by the core parquet scan and 
contrib scans that
+   * delegate to the same native parquet machinery (e.g. a Delta scan contrib, 
which passes
+   * physical-name schemas under column mapping). Returns `None` when an 
output data type cannot
+   * be serialized; the caller is responsible for tagging a fallback reason.
+   *
+   * Visibility note: `private[comet]` means a contrib caller must live under 
an
+   * `org.apache.comet.*` package (the same constraint `PlanDataInjector` 
implementers have).
+   */
+  private[comet] def buildNativeScanCommon(
+      source: String,
+      output: Seq[Attribute],
+      requiredSchema: StructType,
+      dataSchema: StructType,
+      partitionSchema: StructType,
+      fileConstantMetadataColumns: Seq[AttributeReference],
+      dataFilters: Seq[Expression],
+      firstFileUri: Option[URI],
+      hadoopConf: Configuration,
+      conf: SQLConf): Option[OperatorOuterClass.NativeScanCommon.Builder] = {
     val commonBuilder = OperatorOuterClass.NativeScanCommon.newBuilder()
 
     // Set source in common (used as part of injection key)
-    commonBuilder.setSource(scan.simpleStringWithNodeId())
+    commonBuilder.setSource(source)
 
-    val scanTypes = scan.output.flatten { attr =>
+    val scanTypes = output.flatten { attr =>
       serializeDataType(attr.dataType)
     }
 
-    if (scanTypes.length == scan.output.length) {
-      commonBuilder.addAllFields(scanTypes.asJava)
-
-      // Sink operators don't have children
-      builder.clearChildren()
-
-      if (scan.conf.getConf(SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) {
-        val dataFilters = new ListBuffer[Expr]()
-        for (filter <- scan.supportedDataFilters) {
-          exprToProto(filter, scan.output) match {
-            case Some(proto) => dataFilters += proto
-            case _ =>
-              logWarning(s"Unsupported data filter $filter")
-          }
+    if (scanTypes.length != output.length) {
+      // There are unsupported scan types
+      return None
+    }
+    commonBuilder.addAllFields(scanTypes.asJava)
+
+    if (conf.getConf(SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) {
+      val filterProtos = new ListBuffer[Expr]()
+      for (filter <- dataFilters) {
+        exprToProto(filter, output) match {
+          case Some(proto) => filterProtos += proto
+          case _ =>
+            logWarning(s"Unsupported data filter $filter")
         }
-        commonBuilder.addAllDataFilters(dataFilters.asJava)
-      }
-
-      val possibleDefaultValues = 
getExistenceDefaultValues(scan.requiredSchema)
-      if (possibleDefaultValues.exists(_ != null)) {
-        // Our schema has default values. Serialize two lists, one with the 
default values
-        // and another with the indexes in the schema so the native side can 
map missing
-        // columns to these default values.
-        val (defaultValues, indexes) = 
possibleDefaultValues.iterator.zipWithIndex
-          .filter { case (expr, _) => expr != null }
-          .map { case (expr, index) =>
-            // ResolveDefaultColumnsUtil.getExistenceDefaultValues has 
evaluated these
-            // expressions and they should now just be literals.
-            (Literal(expr), index.toLong.asInstanceOf[java.lang.Long])
-          }
-          .toList
-          .unzip
-        commonBuilder.addAllDefaultValues(
-          defaultValues.flatMap(exprToProto(_, scan.output)).asJava)
-        commonBuilder.addAllDefaultValuesIndexes(indexes.asJava)
       }
+      commonBuilder.addAllDataFilters(filterProtos.asJava)
+    }
 
-      // Extract object store options from first file (S3 configs apply to all 
files in scan).
-      // Use selectedPartitions (static) instead of getFilePartitions() 
because at planning time
-      // DPP subqueries haven't been resolved yet. Object store options don't 
depend on DPP.
-      val firstFileUri = scan.selectedPartitions
-        .flatMap(_.files.headOption)
-        .headOption
-        .map(_.getPath.toUri)
-
-      // Constant metadata columns (file_path, file_name, file_size, 
file_block_start,
-      // file_block_length, file_modification_time) are known before opening 
the file and
-      // constant for every row read from it, exactly like partition columns. 
Spark places
-      // them immediately after partition columns in `scan.output`
-      // (FileSourceStrategy.scala: readDataColumns ++ 
generatedMetadataColumns ++
-      // partitionColumns ++ constantMetadataColumns), so appending them after 
the real
-      // partition schema here keeps the two in lockstep.
-      val constantMetadataFields = 
scan.wrapped.fileConstantMetadataColumns.map(attr =>
-        StructField(s"$constantMetadataFieldPrefix${attr.name}", 
attr.dataType, attr.nullable))
-      val partitionSchemaFields = scan.relation.partitionSchema.fields.toSeq ++
-        constantMetadataFields
-      val partitionSchema = schema2Proto(partitionSchemaFields)
-      val requiredSchema = schema2Proto(scan.requiredSchema)
-
-      // Spark's required schema can prune a Variant column, including a 
Variant nested under an
-      // unrequested struct. The complete relation schema still contains that 
unsupported type,
-      // and serializing it would throw even though the native reader never 
needs those bytes.
-      // Keep ordinary fields unchanged and replace a requested 
Variant-bearing root with its
-      // already-validated, pruned required field. A requested actual Variant 
never reaches this
-      // point because CometScanRule keeps those scans on Spark.
-      val nativeDataSchema = 
StructType(scan.relation.dataSchema.fields.flatMap { field =>
-        if (containsVariantType(field.dataType)) {
-          scan.requiredSchema.fields.find(requiredField =>
-            scan.conf.resolver(requiredField.name, field.name))
-        } else {
-          Some(field)
-        }
-      })
-      val dataSchema = schema2Proto(nativeDataSchema)
-
-      val dataSchemaIndexes = scan.requiredSchema.map(field => {
-        nativeDataSchema.fieldIndex(field.name)
-      })
-      val partitionSchemaIndexes = nativeDataSchema.fields.length until
-        (nativeDataSchema.length + partitionSchemaFields.length)
-
-      val projectionVector = (dataSchemaIndexes ++ 
partitionSchemaIndexes).map(idx =>
-        idx.toLong.asInstanceOf[java.lang.Long])
-
-      commonBuilder.addAllProjectionVector(projectionVector.asJava)
-
-      // In `CometScanRule`, we ensure partitionSchema (including constant 
metadata columns)
-      // is supported.
-      assert(partitionSchema.length == partitionSchemaFields.length)
-
-      commonBuilder.addAllDataSchema(dataSchema.asJava)
-      commonBuilder.addAllRequiredSchema(requiredSchema.asJava)
-      commonBuilder.addAllPartitionSchema(partitionSchema.asJava)
-      
commonBuilder.setSessionTimezone(scan.conf.getConfString("spark.sql.session.timeZone"))
-      
commonBuilder.setCaseSensitive(scan.conf.getConf[Boolean](SQLConf.CASE_SENSITIVE))
-
-      // SPARK-53535 (Spark 4.1+): when reading a struct whose requested 
fields are all
-      // missing in the Parquet file, the new default preserves the parent 
struct's
-      // nullness from the file (so non-null parents materialize as a struct 
of all-null
-      // fields). Pre-4.1 Spark hardcodes the legacy behavior (whole struct 
null), which
-      // matches the Comet default we use as fallback.
-      val returnNullStructConfKey =
-        "spark.sql.legacy.parquet.returnNullStructIfAllFieldsMissing"
-      val returnNullStructDefault = if (isSpark41Plus) "false" else "true"
-      commonBuilder.setReturnNullStructIfAllFieldsMissing(
-        scan.conf.getConfString(returnNullStructConfKey, 
returnNullStructDefault).toBoolean)
-
-      // Field-ID matching: only ask the native side to do extra work when the 
conf is on AND
-      // the requested schema actually carries IDs. Spark's ParquetReadSupport 
applies the same
-      // gate before invoking matchIdField.
-      val useFieldId =
-        scan.conf.getConf(SQLConf.PARQUET_FIELD_ID_READ_ENABLED) &&
-          ParquetUtils.hasFieldIds(scan.requiredSchema)
-      commonBuilder.setUseFieldId(useFieldId)
-      commonBuilder.setIgnoreMissingFieldId(
-        scan.conf.getConf(SQLConf.IGNORE_MISSING_PARQUET_FIELD_ID))
-
-      
commonBuilder.setAllowTypePromotion(CometConf.COMET_SCHEMA_EVOLUTION_ENABLED)
-      
commonBuilder.setAllowTimestampLtzToNtz(CometConf.COMET_ALLOW_TIMESTAMP_LTZ_AS_NTZ)
-
-      // Collect S3/cloud storage configurations
-      val hadoopConf = scan.relation.sparkSession.sessionState
-        .newHadoopConfWithOptions(scan.relation.options)
-
-      
commonBuilder.setEncryptionEnabled(CometParquetUtils.encryptionEnabled(hadoopConf))
-
-      firstFileUri.foreach { uri =>
-        val objectStoreOptions =
-          NativeConfig.extractObjectStoreOptions(hadoopConf, uri)
-        objectStoreOptions.foreach { case (key, value) =>
-          commonBuilder.putObjectStoreOptions(key, value)
+    val possibleDefaultValues = getExistenceDefaultValues(requiredSchema)
+    if (possibleDefaultValues.exists(_ != null)) {
+      // Our schema has default values. Serialize two lists, one with the 
default values
+      // and another with the indexes in the schema so the native side can map 
missing
+      // columns to these default values.
+      val (defaultValues, indexes) = 
possibleDefaultValues.iterator.zipWithIndex
+        .filter { case (expr, _) => expr != null }
+        .map { case (expr, index) =>
+          // ResolveDefaultColumnsUtil.getExistenceDefaultValues has evaluated 
these
+          // expressions and they should now just be literals.
+          (Literal(expr), index.toLong.asInstanceOf[java.lang.Long])
         }
+        .toList
+        .unzip
+      commonBuilder.addAllDefaultValues(defaultValues.flatMap(exprToProto(_, 
output)).asJava)
+      commonBuilder.addAllDefaultValuesIndexes(indexes.asJava)
+    }
+
+    // Constant metadata columns (file_path, file_name, file_size, 
file_block_start,
+    // file_block_length, file_modification_time) are known before opening the 
file and
+    // constant for every row read from it, exactly like partition columns. 
Spark places
+    // them immediately after partition columns in the scan output
+    // (FileSourceStrategy.scala: readDataColumns ++ generatedMetadataColumns 
++
+    // partitionColumns ++ constantMetadataColumns), so appending them after 
the real
+    // partition schema here keeps the two in lockstep.
+    val constantMetadataFields = uniqueConstantMetadataFields(
+      fileConstantMetadataColumns,
+      dataSchema.fields.map(_.name).toSet ++ 
partitionSchema.fields.map(_.name).toSet)
+    val partitionSchemaFields = partitionSchema.fields.toSeq ++ 
constantMetadataFields
+    val partitionSchemaProto = schema2Proto(partitionSchemaFields)
+    val requiredSchemaProto = schema2Proto(requiredSchema)
+
+    // Spark's required schema can prune a Variant column, including a Variant 
nested under an
+    // unrequested struct. The complete relation schema still contains that 
unsupported type,
+    // and serializing it would throw even though the native reader never 
needs those bytes.
+    // Keep ordinary fields unchanged and replace a requested Variant-bearing 
root with its
+    // already-validated, pruned required field. A requested actual Variant 
never reaches this
+    // point because CometScanRule keeps those scans on Spark.
+    val nativeDataSchema = StructType(dataSchema.fields.flatMap { field =>
+      if (containsVariantType(field.dataType)) {
+        requiredSchema.fields.find(requiredField => 
conf.resolver(requiredField.name, field.name))
+      } else {
+        Some(field)
       }
+    })
+    val dataSchemaProto = schema2Proto(nativeDataSchema)
 
-      // Set common data in NativeScan (file_partition will be populated at 
execution time)
-      nativeScanBuilder.setCommon(commonBuilder.build())
+    val dataSchemaIndexes = requiredSchema.map(field => {
+      nativeDataSchema.fieldIndex(field.name)
+    })
+    val partitionSchemaIndexes = nativeDataSchema.fields.length until
+      (nativeDataSchema.length + partitionSchemaFields.length)
 
-      Some(builder.setNativeScan(nativeScanBuilder).build())
+    val projectionVector = (dataSchemaIndexes ++ 
partitionSchemaIndexes).map(idx =>
+      idx.toLong.asInstanceOf[java.lang.Long])
 
-    } else {
-      // There are unsupported scan type
-      withFallbackReason(
-        scan,
-        s"unsupported Comet operator: ${scan.nodeName}, due to unsupported 
data types above")
-      None
+    commonBuilder.addAllProjectionVector(projectionVector.asJava)
+
+    // In `CometScanRule`, we ensure partitionSchema (including constant 
metadata columns)
+    // is supported.
+    assert(partitionSchemaProto.length == partitionSchemaFields.length)
+
+    commonBuilder.addAllDataSchema(dataSchemaProto.asJava)
+    commonBuilder.addAllRequiredSchema(requiredSchemaProto.asJava)
+    commonBuilder.addAllPartitionSchema(partitionSchemaProto.asJava)
+
+    populateScanConfFlags(commonBuilder, requiredSchema, firstFileUri, 
hadoopConf, conf)
+
+    Some(commonBuilder)
+  }
+
+  /**
+   * Populate the configuration-derived flags of a `NativeScanCommon`: session 
timezone, case
+   * sensitivity, struct-nullness legacy flag, field-ID matching, type 
promotion, encryption, and
+   * object-store options. Shared with contrib scans that assemble their own 
schemas/projection
+   * (e.g. the Delta contrib's deletion-vector shape) so new flags added here 
reach them without
+   * drift.
+   */
+  private[comet] def populateScanConfFlags(
+      commonBuilder: OperatorOuterClass.NativeScanCommon.Builder,
+      requiredSchema: StructType,
+      firstFileUri: Option[URI],
+      hadoopConf: Configuration,
+      conf: SQLConf): Unit = {
+    
commonBuilder.setSessionTimezone(conf.getConfString("spark.sql.session.timeZone"))
+    val caseSensitive = conf.getConf[Boolean](SQLConf.CASE_SENSITIVE)
+    commonBuilder.setCaseSensitive(caseSensitive)
+    if (!caseSensitive) {
+      // Ship THIS JVM's case data so native's case-insensitive footer 
matching reproduces
+      // this JVM's `toLowerCase(Locale.ROOT)` exactly, whatever Unicode 
version it bundles.
+      JvmCaseTables.populate(commonBuilder)

Review Comment:
   Attaching the tables here means every `NativeScanCommon` carries its own 
copy with no dedup across scans in a plan. I measured 18,801 bytes per scan.
   
   Running `select k1, d1 from t1` repeated under `UNION ALL` and summing the 
serialized native blocks:
   
   | scans | `caseSensitive=true` | `caseSensitive=false` (the default) |
   |---|---|---|
   | 1 | 165 B | 18,966 B |
   | 2 | 330 B | 37,932 B |
   | 3 | 495 B | 56,898 B |
   
   That payload rides in the Spark task binary for every stage, so a 20-table 
query would ship around 380 KB of identical tables.
   
   The change to `QueryContextInternerSuite` in this PR is what led me here. 
That suite asserts `before > after * 4` to protect the SQL-text interning 
optimization, and pinning it to `spark.sql.caseSensitive=true` is what keeps it 
passing. On the default config it fails: I get `before=33316 after=21767 
ratio=1.53` against `before=14513 after=2964 ratio=4.90` with case sensitivity 
on. In other words the plan is now larger than it was before interning landed, 
and the suite no longer covers the default configuration.
   
   The tables are a pure function of the JDK and identical for every scan in a 
plan. Could they be interned at the root `Operator` the same way 
`sql_text_pool` already is, so a plan pays once rather than once per scan? 
Better still, sending them to each executor once at plugin init keyed by the 
fingerprint would take the per-plan cost to zero and let 
`QueryContextInternerSuite` go back to testing the default config.



##########
contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanSupport.scala:
##########
@@ -0,0 +1,1707 @@
+/*
+ * 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.comet.contrib.delta
+
+import java.io.IOException
+import java.net.URI
+import java.util.Locale
+
+import scala.collection.mutable.{ListBuffer, Map => MutableMap}
+import scala.jdk.CollectionConverters._
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.Path
+import org.apache.spark.sql.catalyst.expressions.{Alias, GenericInternalRow, 
InputFileBlockLength, InputFileBlockStart, InputFileName}
+import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, GenericArrayData}
+import 
org.apache.spark.sql.catalyst.util.ResolveDefaultColumns.getExistenceDefaultValues
+import org.apache.spark.sql.comet.CometScanExec
+import org.apache.spark.sql.delta.DeltaParquetFileFormat
+import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor
+import org.apache.spark.sql.execution.{FileSourceScanExec, ProjectExec, 
SparkPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType}
+
+import org.apache.comet.CometConf
+import org.apache.comet.CometConf.COMET_LIBHDFS_SCHEMES
+import org.apache.comet.parquet.CometParquetUtils
+import org.apache.comet.rules.{CometScanRule, CometScanTypeChecker}
+import org.apache.comet.serde.operator.CometNativeScan
+import org.apache.comet.shims.ShimFileFormat
+
+/**
+ * Claim/decline gates for the native Delta scan. Correctness rule: when in 
doubt, decline,
+ * Spark's Delta reader handles the scan and results stay correct, just 
unaccelerated.
+ */
+object DeltaScanSupport {
+
+  /**
+   * Reader features the native path understands; anything else on the 
protocol declines the
+   * table. `deletionVectors`/`columnMapping` are declined separately below 
for specific reasons.
+   */
+  private val understoodReaderFeatures: Set[String] =
+    Set("columnMapping", "deletionVectors", "timestampNtz", "v2Checkpoint", 
"vacuumProtocolCheck")
+
+  /**
+   * Is this exactly Delta's DSv1 parquet format? Compared by class name, not 
`classOf`: a
+   * `classOf` reference would raise `NoClassDefFoundError` and break every 
parquet scan when
+   * delta-spark is absent from the classpath.
+   */
+  def isDeltaScan(scanExec: FileSourceScanExec): Boolean =
+    scanExec.relation.fileFormat.getClass.getName ==
+      "org.apache.spark.sql.delta.DeltaParquetFileFormat"
+
+  /**
+   * Claim-time artifacts [[declineReason]] already computes but 
[[CometDeltaNativeScan.convert]]
+   * also needs -- threaded through by reference (populated only on the 
claimable path, right
+   * before `declineReason` returns `None`) so a claimed scan does not pay to 
recompute either:
+   * the Hadoop conf 
([[org.apache.spark.sql.internal.SessionState#newHadoopConfWithOptions]] is
+   * not cheap) and the deletion-vector descriptors (base64-decoded, 
non-trivial only for DV-shape
+   * scans). One instance is created per claim attempt in `DeltaScanContrib` 
and passed to both
+   * `declineReason` and `convert`.
+   */
+  private[delta] final class DeltaClaimMemo {
+    var hadoopConf: Configuration = _
+    var dvDescriptors: Seq[DeletionVectorDescriptor] = Seq.empty
+  }
+
+  /**
+   * First reason this Delta scan cannot go native, or None when claimable (in 
which case `memo`
+   * is populated for [[CometDeltaNativeScan.convert]] to reuse). Only called 
when [[isDeltaScan]]
+   * is true. `scanHelper` is the [[CometScanExec]] built to drive `convert` 
on a claim, reused
+   * for the multi-store gate below.
+   */
+  def declineReason(
+      plan: SparkPlan,
+      scanExec: FileSourceScanExec,
+      scanHelper: CometScanExec,
+      memo: DeltaClaimMemo): Option[String] = {
+    val format = 
scanExec.relation.fileFormat.asInstanceOf[DeltaParquetFileFormat]
+    val protocol = format.protocol
+    val metadata = format.metadata
+    // Name mode is supported via physical-name schemas; id mode needs the 
field-id path and
+    // stays declined until validated. Hoisted here since several gates below 
reuse it.
+    val cmMode = metadata.columnMappingMode.name
+    // Descriptor deserialization is expensive, so hoist it into a `lazy val`, 
forced at most
+    // once in this method; on the claimable path the result is handed to 
`convert` through
+    // `memo` below, so a claimed scan deserializes the descriptors exactly 
once end to end.
+    val tableRoot = scanExec.relation.location.rootPaths.head.toString
+    lazy val dvDescriptors: Seq[DeletionVectorDescriptor] =
+      selectedDvDescriptors(scanHelper, tableRoot)
+
+    // Mirrors core's CometScanRule.isSchemaSupported so scan-time type gates 
(unsigned-small-int
+    // fallback, collation, shredded-variant-struct) apply identically here. 
Pure in-memory check,
+    // so it runs first, ahead of every I/O-bearing gate below.
+    val schemaFallbackReasons = new ListBuffer[String]()
+    val typeChecker = CometScanTypeChecker()
+    val requiredSchemaSupported =
+      typeChecker.isSchemaSupported(scanExec.requiredSchema, 
schemaFallbackReasons)
+    val partitionSchemaSupported =
+      typeChecker.isSchemaSupported(scanExec.relation.partitionSchema, 
schemaFallbackReasons)
+    if (!requiredSchemaSupported || !partitionSchemaSupported) {
+      return Some(
+        "Native Delta scan does not support the schema: " + 
schemaFallbackReasons.mkString(", "))
+    }
+
+    if (format.isCDCRead) {
+      return Some("Native Delta scan does not support Change Data Feed reads")
+    }
+
+    // Delta's DML machinery (findTouchedFiles) disables reader optimizations 
and needs real
+    // row indexes from Spark's reader; claiming here would feed NULL indexes 
into DV construction.
+    if (!format.optimizationsEnabled) {
+      return Some("Native Delta scan does not support reads with reader 
optimizations disabled")
+    }
+    if (scanExec.requiredSchema.exists(_.name == 
DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME) ||
+      scanExec.relation.dataSchema.exists(
+        _.name == DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME)) {
+      return Some("Native Delta scan does not support Delta's generated 
row-index column")
+    }
+
+    if (cmMode != "none" && cmMode != "name") {
+      return Some(s"Native Delta scan does not support column mapping mode 
$cmMode")
+    }
+    // createPhysicalSchema wholesale-replaces field metadata, silently 
dropping EXISTS_DEFAULT.
+    if (cmMode == "name" &&
+      getExistenceDefaultValues(scanExec.requiredSchema).exists(_ != null)) {
+      return Some(
+        "Native Delta scan does not support column defaults together with 
column mapping")
+    }
+    // createPhysicalSchema rewrites nested StructField names too, and the 
native builder emits the
+    // required schema verbatim as output, so name-sensitive expressions (e.g. 
to_json) would leak
+    // physical names. Decline until a rename adapter exists.
+    if (cmMode == "name" &&
+      scanExec.requiredSchema.exists(f => containsNestedStruct(f.dataType))) {
+      return Some("Native Delta scan does not support column mapping with 
nested struct fields")
+    }
+
+    val readerFeatures = protocol.readerFeatureNames
+    val unknownFeatures = readerFeatures -- understoodReaderFeatures
+    if (unknownFeatures.nonEmpty) {
+      return Some(
+        s"Native Delta scan does not support reader feature(s) 
${unknownFeatures.mkString(", ")}")
+    }
+
+    // Non-constant metadata columns are generated per-row by Spark's reader 
and unsupported,
+    // except Delta's DV bookkeeping columns, which the native path emits as 
constants.
+    val knownColNames =
+      scanExec.relation.dataSchema.map(_.name).toSet ++
+        scanExec.relation.partitionSchema.map(_.name).toSet ++
+        scanExec.fileConstantMetadataColumns.map(_.name).toSet ++
+        CometDeltaNativeScan.internalColumnNames
+    val unknownOutput = 
scanExec.output.map(_.name).filterNot(knownColNames.contains)
+    if (unknownOutput.nonEmpty) {
+      return Some(
+        s"Native Delta scan does not support generated column(s) 
${unknownOutput.mkString(", ")}")
+    }
+
+    // Deletion-vector shape invariants (see 
CometDeltaNativeScan.buildDvScanCommon).
+    if (CometDeltaNativeScan.isDvShape(scanExec)) {
+      // A row-index column WITHOUT is_row_deleted is Delta DML bookkeeping 
(real row indexes),
+      // not a DV read; claiming it with a constant would corrupt the DVs 
being written.
+      val hasIsRowDeleted =
+        scanExec.requiredSchema.exists(_.name == 
CometDeltaNativeScan.IsRowDeletedColumn)
+      val hasRowIndex =
+        scanExec.requiredSchema.exists(_.name == 
CometDeltaNativeScan.RowIndexColumn)
+      if (hasRowIndex && !hasIsRowDeleted) {
+        return Some(
+          "Native Delta scan does not support row-index reads outside a 
deletion-vector scan")
+      }
+      // Internal columns must form a suffix of the read schema so data-column 
positions agree
+      // between Spark's output and the stripped native schema.
+      val names = scanExec.requiredSchema.fields.map(_.name)
+      val firstInternal = 
names.indexWhere(CometDeltaNativeScan.internalColumnNames.contains)
+      if 
(!names.drop(firstInternal).forall(CometDeltaNativeScan.internalColumnNames.contains))
 {
+        return Some("Native Delta scan requires DV bookkeeping columns to 
trail the read schema")
+      }
+      // Native applies the DV itself and emits a dead constant for row-index, 
so the real value
+      // must be provably unused above the scan.
+      if (!rowIndexUnusedAbove(plan, scanExec)) {
+        return Some(
+          "Native Delta scan cannot supply _metadata.row_index values consumed 
by the query")
+      }
+      // The DV common builder does not serialize existence defaults yet.
+      if (getExistenceDefaultValues(scanExec.requiredSchema).exists(_ != 
null)) {
+        return Some(
+          "Native Delta scan does not support column defaults together with 
deletion vectors")
+      }
+      // Bounds native's memory for expanded DV row selectors (delta_dv.rs), 
pessimistically
+      // bounded by 2*cardinality + #row-groups; the conf below makes an 
over-pessimistic decline
+      // recoverable.
+      val maxDeletedRowsPerFile = 
DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.get()
+      val oversizedCardinalities = dvDescriptors
+        .map(_.cardinality)
+        .filter(_ > maxDeletedRowsPerFile)
+      if (oversizedCardinalities.nonEmpty) {
+        return Some(
+          "Native Delta scan does not support a deletion vector deleting " +
+            s"${oversizedCardinalities.max} rows in a single file, exceeding " 
+
+            
s"${DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key}=$maxDeletedRowsPerFile")
+      }
+    }
+
+    // input_file_name & friends read from a thread-local Spark's FileScanRDD 
sets; the native scan
+    // does not populate it, and Delta's DML find-touched-files scans use it 
(mirrors core's check
+    // in CometScanRule.nativeScan).
+    if (plan.exists(node =>
+        node.expressions.exists(_.exists {
+          case _: InputFileName | _: InputFileBlockStart | _: 
InputFileBlockLength => true
+          case _ => false
+        }))) {
+      return Some(
+        "Native Delta scan is not compatible with input_file_name, " +
+          "input_file_block_start, or input_file_block_length")
+    }
+
+    // Row-index metadata columns are generated per-row by Spark's reader 
(mirrors core); the DV
+    // shape's trailing row-index column is exempt since the gates above 
already proved it dead.
+    if (!CometDeltaNativeScan.isDvShape(scanExec) &&
+      ShimFileFormat.findRowIndexColumnIndexInSchema(scanExec.requiredSchema) 
>= 0) {
+      return Some("Native Delta scan does not support row index generation")
+    }
+
+    // Mirror core's vectorized-reader compatibility gate.
+    if (!SQLConf.get.getConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED) &&
+      !CometConf.COMET_SCAN_ALLOW_DISABLED_PARQUET_VECTORIZED_READER.get()) {
+      return Some(
+        "Native Delta scan is incompatible with " +
+          s"${SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key}=false")
+    }
+
+    // Decline ALL encrypted-parquet configurations (stricter than core): the 
exec node does not
+    // yet wire the decryption-key broadcast to executors.
+    val hadoopConf = scanExec.relation.sparkSession.sessionState
+      .newHadoopConfWithOptions(scanExec.relation.options)
+    // Populated now (rather than only at the very end) so it is available 
even though several
+    // early-return gates below still lie ahead: cheap to set, and every one 
of those gates
+    // declines the scan anyway, so `memo` is simply never read by `convert` 
in that case.
+    memo.hadoopConf = hadoopConf
+    if (CometParquetUtils.encryptionEnabled(hadoopConf)) {
+      return Some("Native Delta scan does not support encrypted parquet")
+    }
+
+    // Nested-type column defaults cannot be serialized; a dropped default 
would misalign the
+    // value/index lists consumed positionally on the native side. Mirrors 
core's
+    // transformV1Scan gate.
+    val possibleDefaultValues = 
getExistenceDefaultValues(scanExec.requiredSchema)
+    if (possibleDefaultValues.exists(d =>
+        d != null && (d.isInstanceOf[ArrayBasedMapData] || d
+          .isInstanceOf[GenericInternalRow] || 
d.isInstanceOf[GenericArrayData]))) {
+      return Some("Native Delta scan does not support default values for 
nested types")
+    }
+
+    // Only claim scans whose root paths object_store (or the configured 
libhdfs schemes) can
+    // actually read (mirrors core's unsupportedFsSchemes gate).
+    val libhdfs = libhdfsSchemes
+    val unsupportedRootSchemes =
+      unsupportedSchemes(scanExec.relation.location.rootPaths.map(_.toUri), 
libhdfs)
+    if (unsupportedRootSchemes.nonEmpty) {
+      return Some(
+        "Native Delta scan does not support filesystem scheme(s) " +
+          s"${unsupportedRootSchemes.mkString(", ")}")
+    }
+
+    // A shallow clone can span multiple object-store authorities, but the 
native builder resolves
+    // ObjectStoreUrl from only the FIRST selected file; force file listing 
and decline rather than
+    // risk reading a later file through the wrong handle.
+    val dataFileUris =
+      
scanHelper.selectedPartitions.iterator.flatMap(_.files).map(_.getPath.toUri).toSeq
+
+    // Both gates below need the DV absolute-path URIs; dvDescriptors is 
already memoized.
+    val dvUris = dvDescriptors
+      .filter(_.storageType != DeletionVectorDescriptor.INLINE_DV_MARKER)
+      .map(_.absolutePath(new Path(tableRoot)).toUri)
+
+    // The root-path gate above only inspects the table root(s); selected 
files can resolve
+    // through a different scheme (e.g. `viewfs:`). Checked before the 
authority gates below,
+    // which presume every URI is natively resolvable.
+    val unsupportedSelected = unsupportedSelectedSchemeReason(dataFileUris ++ 
dvUris, libhdfs)
+    if (unsupportedSelected.isDefined) {
+      return unsupportedSelected
+    }
+
+    // Checked before multiStoreReason, which presumes every URI resolves to a 
single store
+    // identity -- a userinfo-bearing authority provably does not (store 
keying drops userinfo).
+    val userInfoReason = userInfoBearingAuthorityReason(dataFileUris ++ dvUris)
+    if (userInfoReason.isDefined) {
+      return userInfoReason
+    }
+
+    val multiStore = multiStoreReason(dataFileUris)
+    if (multiStore.isDefined) {
+      return multiStore
+    }
+
+    // GCS's zero-I/O, conf-only credential-forwarding gate; ordered alongside 
the S3 credential
+    // gates below since all presume a single, well-formed store identity per 
URI.
+    val gcsAuthReason = gcsHadoopOnlyAuthReason(hadoopConf, dataFileUris ++ 
dvUris)
+    if (gcsAuthReason.isDefined) {
+      return gcsAuthReason
+    }
+
+    // Zero-I/O, conf-only, like the GCS gate above: decline any bucket 
configured for an
+    // encryption algorithm outside the allowlist (SSE-C, CSE-KMS, CSE-CUSTOM, 
or unknown) before
+    // the credential-divergence gates below, which do not otherwise notice 
this table is readable
+    // through Hadoop only because Hadoop's request factory (SSE-C) or 
SDK-level decryption layer
+    // (CSE-*) does something native never learns about.
+    val encryptionReason =
+      unsupportedEncryptionAlgorithmReason(hadoopConf, dataFileUris ++ dvUris)
+    if (encryptionReason.isDefined) {
+      return encryptionReason
+    }
+
+    // Shared across the two gates below: propagateBucketOptions is a full 
Configuration deep
+    // copy, and both gates would otherwise recompute it independently for the 
same bucket(s)
+    // (once here, then again per-key inside s3ConfigDivergenceReason). One 
cache, populated
+    // lazily per bucket on first use, makes it a single copy total per bucket 
across both gates.
+    val propagatedConfCache = MutableMap.empty[String, Configuration]
+
+    // Always zero-I/O (plain propagated-conf read, no keystore): native's S3 
client has no
+    // HTTP proxy support at all (no fs.s3a.proxy.* key is read anywhere in 
s3.rs), so a bucket
+    // requiring a proxy for S3 egress must decline here rather than claim and 
then connect
+    // directly, bypassing whatever network-segmentation/firewall policy 
required the proxy.
+    val proxyReason = proxyGateReason(hadoopConf, dataFileUris ++ dvUris, 
propagatedConfCache)
+    if (proxyReason.isDefined) {
+      return proxyReason
+    }
+
+    // Every fs.s3a.* option native's get_config (s3.rs) resolves must agree 
between what Hadoop
+    // itself would use and what native would read from the forwarded, 
substituted conf (covers
+    // long-form bucket credentials, JCEKS/credential-provider shadowing, and 
any other
+    // short-vs-effective divergence in one mechanism); reuses hadoopConf from 
the encryption gate
+    // above.
+    val s3Reason =
+      s3ConfigDivergenceReason(hadoopConf, dataFileUris ++ dvUris, 
propagatedConfCache)
+    if (s3Reason.isDefined) {
+      return s3Reason
+    }
+
+    // A credential-provider class native's 
build_aws_credential_provider_metadata (s3.rs) does
+    // not recognize errors at scan EXECUTION time, after the scan was already 
claimed; decline
+    // eagerly instead.
+    val providerReason = providerClassGateReason(hadoopConf, dataFileUris ++ 
dvUris)
+    if (providerReason.isDefined) {
+      return providerReason
+    }
+
+    // Reuse core's generic native-scan gates 
(ignoreCorruptFiles/ignoreMissingFiles, AQE DPP on
+    // Spark 3.4, exec enabled); tags its own fallback reasons.
+    if (!CometNativeScan.isSupported(scanExec)) {
+      return Some("Core native scan gates rejected the scan (see reasons 
above)")
+    }
+
+    // Claimable: hand the already-forced descriptors to `convert` via `memo` 
so it does not
+    // deserialize them a second time.
+    memo.dvDescriptors = dvDescriptors
+    None
+  }
+
+  /**
+   * Deletion-vector descriptors for every file this DV-shape scan selected, 
normalized to
+   * absolute on-disk paths. Returns `Seq.empty` for the plain shape. Shared 
by the DV cardinality
+   * gate and [[CometDeltaNativeScan.convert]]'s object-store option merge.
+   */
+  private[delta] def selectedDvDescriptors(
+      scanHelper: CometScanExec,
+      tableRoot: String): Seq[DeletionVectorDescriptor] = {
+    if (!CometDeltaNativeScan.isDvShape(scanHelper.wrapped)) {
+      return Seq.empty
+    }
+    val tableRootPath = new Path(tableRoot)
+    scanHelper.selectedPartitions.iterator
+      .flatMap(_.files)
+      .flatMap { file =>
+        file.metadata
+          .get(DeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED)
+          .map(enc => 
DeletionVectorDescriptor.deserializeFromBase64(enc.asInstanceOf[String]))
+      }
+      .map(_.copyWithAbsolutePath(tableRootPath))
+      .toSeq
+  }
+
+  /**
+   * The libhdfs scheme exemption set from 
[[org.apache.comet.CometConf.COMET_LIBHDFS_SCHEMES]],
+   * lowercased and defaulting to `Set("hdfs")` when unset.
+   */
+  private[delta] def libhdfsSchemes: Set[String] = COMET_LIBHDFS_SCHEMES.get() 
match {
+    case Some(s) =>
+      
s.split(",").map(_.trim.toLowerCase(Locale.ROOT)).filter(_.nonEmpty).toSet
+    case None => Set("hdfs")
+  }
+
+  /**
+   * The lowercased, deduplicated schemes among `uris` that neither `libhdfs` 
nor Comet's native
+   * object_store layer ([[CometScanRule.isNativelyReadableScheme]]) can read. 
A `null` scheme is
+   * tolerated, not flagged, since such a URI cannot come from a Hadoop-backed 
source.
+   */
+  private[delta] def unsupportedSchemes(uris: Seq[URI], libhdfs: Set[String]): 
Set[String] = {
+    uris
+      .filter { uri =>
+        val sch = uri.getScheme
+        sch != null && {
+          val sl = sch.toLowerCase(Locale.ROOT)
+          !libhdfs.contains(sl) && !CometScanRule.isNativelyReadableScheme(uri)
+        }
+      }
+      .map(_.getScheme.toLowerCase(Locale.ROOT))
+      .toSet
+  }
+
+  /**
+   * Decline reason when any of `uris` -- the scan's selected data-file and 
deletion-vector URIs
+   * -- use a scheme [[unsupportedSchemes]] flags, or `None` when every URI is 
natively readable
+   * (or libhdfs-exempt).
+   */
+  private[delta] def unsupportedSelectedSchemeReason(
+      uris: Seq[URI],
+      libhdfs: Set[String]): Option[String] = {
+    val schemes = unsupportedSchemes(uris, libhdfs)
+    if (schemes.isEmpty) {
+      None
+    } else {
+      Some(
+        "Native Delta scan does not support selected data file or deletion 
vector filesystem " +
+          s"scheme(s) ${schemes.mkString(", ")}")
+    }
+  }
+
+  /**
+   * Decline reason when `uris` span more than one object-store authority 
(scheme + lowercased raw
+   * authority, so e.g. `S3A://Bucket` and `s3a://bucket` collapse), or `None` 
when they share
+   * one. `file://` paths carry no authority, so local scans across many 
directories are
+   * unaffected.
+   */
+  private[delta] def multiStoreReason(uris: Seq[URI]): Option[String] = {
+    val authorities = uris.map(uriAuthority).distinct
+    if (authorities.size > 1) {
+      Some(
+        "Native Delta scan does not support data files spanning multiple 
object stores " +
+          s"(found: ${authorities.sorted.mkString(", ")})")
+    } else {
+      None
+    }
+  }
+
+  /**
+   * Normalizes `uri` to a lowercased `scheme://authority` string, keyed on 
the raw `getAuthority`
+   * rather than the parsed host/port/userinfo fields: `getHost` (and 
`getUserInfo`/`getPort`)
+   * return `null` for the whole authority when it fails RFC 3986 `reg-name` 
syntax (e.g. an
+   * underscore in a GCS bucket name, `gs://my_bucket`), which would silently 
collapse distinct
+   * buckets into one empty-host key. A `null` authority normalizes to the 
empty string.
+   */
+  private[delta] def uriAuthority(uri: URI): String = {
+    val scheme = 
Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)).getOrElse("")
+    val authority = 
Option(uri.getAuthority).map(_.toLowerCase(Locale.ROOT)).getOrElse("")
+    s"$scheme://$authority"
+  }
+
+  /**
+   * The raw userinfo component of `uri`'s authority, or empty when none. 
Splits at the LAST `@`
+   * rather than using `URI#getUserInfo`, which (like [[uriAuthority]]'s 
getters) returns `null`
+   * for the whole authority on an RFC 3986 `reg-name` violation. Never 
lowercased: userinfo is
+   * case-sensitive.
+   */
+  private[delta] def uriUserInfo(uri: URI): String = {
+    val authority = Option(uri.getAuthority).getOrElse("")
+    val at = authority.lastIndexOf('@')
+    if (at >= 0) authority.substring(0, at) else ""
+  }
+
+  /**
+   * Redacts `uri`'s authority to `scheme`, then `://`, then a literal `***` 
masking userinfo,
+   * then `@host[:port]`, for embedding in a decline reason. NEVER interpolate 
`uri.getAuthority`
+   * or [[uriUserInfo]] directly into a reason string: doing so would leak 
credentials embedded as
+   * URI userinfo into the SQL plan's explain output, fallback-reason logging, 
or the Spark UI.
+   */
+  private[delta] def redactedAuthority(uri: URI): String = {
+    val scheme = 
Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)).getOrElse("")
+    val authority = Option(uri.getAuthority).getOrElse("")
+    val at = authority.lastIndexOf('@')
+    val hostPort = if (at >= 0) authority.substring(at + 1) else authority
+    s"$scheme://***@$hostPort"
+  }
+
+  /**
+   * Decline reason when any of `uris` carries userinfo in its authority (e.g. 
the container in an
+   * abfss:// path), or `None` when none do. The native store cache, 
`ObjectStoreUrl`, and
+   * DataFusion registry all key on scheme/host/port only, dropping userinfo, 
so two authorities
+   * differing only in userinfo collide onto the same store handle.
+   */
+  private[delta] def userInfoBearingAuthorityReason(uris: Seq[URI]): 
Option[String] = {
+    val offending = uris.filter(uri => 
uriUserInfo(uri).nonEmpty).map(redactedAuthority).distinct
+    if (offending.isEmpty) {
+      None
+    } else {
+      Some("Native Delta scan does not support object-store paths whose 
authority carries " +
+        "userinfo (e.g. the container in an abfss:// path): the native 
object-store cache, " +
+        "ObjectStoreUrl and DataFusion registry all key on scheme, host and 
port only, so two " +
+        "containers on one storage account share a single store handle " +
+        s"(found: ${offending.sorted.mkString(", ")})")
+    }
+  }
+
+  /**
+   * String-literal Hadoop conf keys consulted below. `hadoop-aws` is NOT on 
this module's runtime
+   * classpath, so `org.apache.hadoop.fs.s3a.Constants` must never be 
referenced here (would raise
+   * `NoClassDefFoundError` for sessions with no S3 dependency).
+   */
+  private val HadoopCredentialProviderPathKey = 
"hadoop.security.credential.provider.path"
+  private val S3aCredentialProviderPathKey = 
"fs.s3a.security.credential.provider.path"
+
+  /**
+   * 
`CommonConfigurationKeysPublic.HADOOP_SECURITY_CREDENTIAL_CLEAR_TEXT_FALLBACK`, 
default
+   * `true`, verified via `javap` against `hadoop-common` 3.3.4's
+   * `Configuration#getPasswordFromConfig`: `getPassword` only falls back to 
reading a plaintext
+   * conf value once `getBoolean(<this key>, true)` holds -- with the flag 
off, a plaintext value
+   * is invisible to every `getPassword`-based resolver, even when no 
credential provider is
+   * configured at all.
+   */
+  private val ClearTextFallbackKey = 
"hadoop.security.credential.clear-text-fallback"
+
+  private def s3aBucketProviderPathKey(bucket: String): String =
+    s"fs.s3a.bucket.$bucket.security.credential.provider.path"
+
+  /**
+   * The LONG form of [[s3aBucketProviderPathKey]]: `S3AUtils#lookupPassword` 
resolves per-bucket
+   * overrides through both a long key (`fs.s3a.bucket.B.<full base key>`) and 
a short key; both
+   * must be covered here too.
+   */
+  private def s3aBucketLongProviderPathKey(bucket: String): String =
+    s"fs.s3a.bucket.$bucket.fs.s3a.security.credential.provider.path"
+
+  private def nonEmptyConf(hadoopConf: Configuration, key: String): Boolean =
+    Option(hadoopConf.get(key)).exists(_.nonEmpty)
+
+  /**
+   * The lowercase-scheme-checked S3/S3A bucket name from `uri`'s authority, 
or `None` when
+   * `uri`'s scheme is not `s3`/`s3a`. Parses the raw authority manually 
rather than
+   * `URI#getHost`, avoiding the same RFC 3986 `reg-name` pitfall as 
[[uriAuthority]].
+   */
+  private def s3Bucket(uri: URI): Option[String] = {
+    val scheme = Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT))
+    if (scheme.contains("s3") || scheme.contains("s3a")) {
+      val authority = Option(uri.getAuthority).getOrElse("")
+      val at = authority.lastIndexOf('@')
+      val hostAndPort = if (at >= 0) authority.substring(at + 1) else authority
+      val colon = hostAndPort.lastIndexOf(':')
+      val host = if (colon >= 0) hostAndPort.substring(0, colon) else 
hostAndPort
+      if (host.isEmpty) None else Some(host)
+    } else {
+      None
+    }
+  }
+
+  private def plainValue(hadoopConf: Configuration, key: String): 
Option[String] =
+    Option(hadoopConf.get(key)).filter(_.nonEmpty)
+
+  /**
+   * How Hadoop's OWN consumer reads one of the keys compared by 
[[s3ConfigDivergenceReason]],
+   * which decides how [[s3KeyDivergenceReason]] computes the Hadoop-effective 
side of its
+   * equality check. Exactly two consumer families exist among 
[[AllS3ConfigKeys]] in `hadoop-aws`
+   * 3.3.4, each verified via `javap`/CFR against the real call sites (cited 
per key on
+   * [[S3ConfigKeyConsumers]]). The tier must mirror the key's ACTUAL 
consumer: resolving a
+   * [[PropagatedOptionConsumer]] key through the wider `lookupPassword` 
cascade is NOT fail-safe
+   * for a value-EQUALITY comparator -- a long-form alias value Hadoop itself 
never reads can
+   * EQUAL native's resolution while Hadoop's true propagate-then-plain-get 
value differs, turning
+   * a real divergence into a wrongly-claimed scan (the endpoint 
`${...}`-redirect shape pinned in
+   * `DeltaScanContribSuite`).
+   */
+  private[delta] sealed trait S3ConfigConsumer
+
+  /**
+   * Read via `S3AUtils#lookupPassword(bucket, conf, baseKey)`, verified via 
`javap` against
+   * `hadoop-aws` 3.3.4: builds `longBucketKey = "fs.s3a.bucket." + bucket + 
"." + baseKey` (the
+   * FULL, already-`fs.s3a`-prefixed base key appended after the bucket 
segment) and reads it via
+   * `Configuration#getPassword` BEFORE the short-bucket key, keeping the long 
value whenever
+   * `getPassword` returns non-empty and only falling through to 
short-then-global otherwise.
+   * `getPassword` is Hadoop-credential-provider-aware and skips plaintext 
conf entirely when
+   * [[ClearTextFallbackKey]] is false. Modeled by 
[[hadoopLookupPasswordEffective]].
+   */
+  private[delta] case object LookupPasswordConsumer extends S3ConfigConsumer
+
+  /**
+   * Read via `S3AUtils#propagateBucketOptions` followed by a plain 
`Configuration#get`-family
+   * call (`getTrimmed`/`getBoolean`/`getClasses`) against the propagated 
view: the short bucket
+   * form wins only by having overwritten the global key during propagation, 
the long bucket form
+   * folds into an unread `fs.s3a.fs.s3a.*` key, and neither a credential 
provider nor
+   * [[ClearTextFallbackKey]] is ever consulted. Modeled as a plain 
`Configuration#get` on the
+   * [[propagateBucketOptions]] result, which also expands `${...}` references 
under that
+   * propagated view exactly like the real consumer.
+   */
+  private[delta] case object PropagatedOptionConsumer extends S3ConfigConsumer
+
+  /**
+   * Every `fs.s3a.*` base key that governs whether a claimed native scan 
actually behaves like
+   * Hadoop's own reader would, paired with the consumer family Hadoop 
resolves it through -- ONE
+   * list, with each key's resolution tier declared beside it, so a key can 
never sit in the
+   * comparator without a deliberate classification (adding one without 
picking a tier does not
+   * compile). The entries are every per-bucket `fs.s3a.*` base key native's 
S3 client's
+   * `get_config` (s3.rs) resolves, verified directly against its call sites:
+   * `extract_s3_config_options` (endpoint.region, path.style.access, endpoint,
+   * requester.pays.enabled), `lookup_provider_class` (the Comet-specific
+   * credential-provider-class activation key), and
+   * `build_credential_provider`/`build_aws_credential_provider_metadata`/
+   * `build_assume_role_credential_provider_metadata` 
(aws.credentials.provider,
+   * assumed.role.credentials.provider, assumed.role.arn, 
assumed.role.session.name).
+   *
+   * Tier assignments, each verified via `javap`/CFR against `hadoop-aws` 
3.3.4:
+   *   - access.key/secret.key/session.token: `S3AUtils#getAWSAccessKeys` and
+   *     `MarshalledCredentialBinding#fromFileSystem` (reached from
+   *     `TemporaryAWSCredentialsProvider`) resolve all three via 
`S3AUtils#lookupPassword` --
+   *     [[LookupPasswordConsumer]].
+   *   - aws.credentials.provider and assumed.role.credentials.provider:
+   *     `S3AUtils#buildAWSProviderList` -> `loadAWSProviderClasses` -> plain
+   *     `Configuration#getClasses` -- [[PropagatedOptionConsumer]].
+   *   - assumed.role.arn/session.name: `AssumedRoleCredentialProvider`'s 
constructor reads both
+   *     via plain `Configuration#getTrimmed` -- [[PropagatedOptionConsumer]].
+   *   - endpoint (`S3AFileSystem`: `getTrimmed`), endpoint.region 
(`DefaultS3ClientFactory`:
+   *     `getTrimmed`), path.style.access (`S3AFileSystem`: `getBoolean`) --
+   *     [[PropagatedOptionConsumer]].
+   *   - requester.pays.enabled: not read anywhere in `hadoop-aws` 3.3.4 (the 
constant does not
+   *     even exist in its `Constants` class); later releases read it via 
plain `getBoolean`
+   *     against the propagated conf, so the plain tier is both the faithful 
forward model and
+   *     inert on 3.3.4 -- [[PropagatedOptionConsumer]].
+   *   - comet.credential.provider.class: Comet's own activation key, plain 
conf read on both
+   *     sides, never a Hadoop key at all -- [[PropagatedOptionConsumer]].
+   *
+   * SYNC NOTE: the key list must stay a superset of native's 
`NATIVE_S3A_CONFIG_PROPERTIES`
+   * constant (`native/core/src/parquet/objectstore/s3.rs`, property suffixes 
without the
+   * `fs.s3a.` prefix) -- `DeltaScanContribSuite`'s discovery-harness test 
asserts this
+   * mechanically against [[AllS3ConfigKeys]]. Literal strings, not the
+   * [[AwsCredentialsProviderKey]] / [[AssumedRoleCredentialsProviderKey]] 
vals declared below,
+   * purely to avoid a forward reference inside this `object` body; kept 
textually identical to
+   * those two constants.
+   */
+  private[delta] val S3ConfigKeyConsumers: Seq[(String, S3ConfigConsumer)] = 
Seq(
+    "fs.s3a.access.key" -> LookupPasswordConsumer,
+    "fs.s3a.secret.key" -> LookupPasswordConsumer,
+    "fs.s3a.session.token" -> LookupPasswordConsumer,
+    "fs.s3a.aws.credentials.provider" -> PropagatedOptionConsumer,
+    "fs.s3a.assumed.role.arn" -> PropagatedOptionConsumer,
+    "fs.s3a.assumed.role.session.name" -> PropagatedOptionConsumer,
+    "fs.s3a.assumed.role.credentials.provider" -> PropagatedOptionConsumer,
+    "fs.s3a.endpoint" -> PropagatedOptionConsumer,
+    "fs.s3a.endpoint.region" -> PropagatedOptionConsumer,
+    "fs.s3a.path.style.access" -> PropagatedOptionConsumer,
+    "fs.s3a.requester.pays.enabled" -> PropagatedOptionConsumer,
+    "fs.s3a.comet.credential.provider.class" -> PropagatedOptionConsumer)
+
+  /** The compared keys alone, in [[S3ConfigKeyConsumers]] order 
(discovery-harness surface). */
+  private[delta] val AllS3ConfigKeys: Seq[String] = 
S3ConfigKeyConsumers.map(_._1)
+
+  /**
+   * The short-bucket-then-global value resolved for `baseKey` under `bucket` 
from `hadoopConf`,
+   * skipping an empty value at either alias exactly like [[plainValue]]. NOT 
used by
+   * [[s3ConfigDivergenceReason]]/[[s3KeyDivergenceReason]] any more -- every 
key checked there
+   * resolves per its declared [[S3ConfigKeyConsumers]] tier (see 
[[s3KeyDivergenceReason]]),
+   * neither of which matches this function's read. This function's one 
remaining caller is
+   * [[shortThenGlobalOrReason]], which reads provider-CLASS strings (from the 
ORIGINAL,
+   * unpropagated conf) for name-support validation in [[providerClassReason]]/
+   * [[assumedRoleProviderClassReason]] -- by the time those run, 
[[s3ConfigDivergenceReason]] has
+   * already proven Hadoop's and native's effective values agree for the same 
key, so whichever of
+   * the two (equal) values this narrower read returns does not affect 
correctness there. NEVER
+   * used to compute native's own effective value -- see 
[[nativeShortThenGlobal]] for that.
+   */
+  private def shortThenGlobal(
+      hadoopConf: Configuration,
+      bucket: String,
+      baseKey: String): Option[String] = {
+    val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+    plainValue(hadoopConf, shortKey).orElse(plainValue(hadoopConf, baseKey))
+  }
+
+  /**
+   * The short-bucket-then-global value native's `get_config` (s3.rs) resolves 
for `baseKey` under
+   * `bucket` from the ORIGINAL, unpropagated `hadoopConf` -- `NativeConfig
+   * .extractObjectStoreOptions` forwards `Configuration#get`'s substituted 
value for every
+   * `fs.s3a.*` entry with no bucket-option propagation step of its own, so 
the original conf is
+   * the right input here. Unlike [[shortThenGlobal]]/[[plainValue]], this 
mirrors `get_config`
+   * faithfully: PRESENCE of the short-bucket key alone -- never its emptiness 
-- decides whether
+   * native falls back to the global key (`get_config` is a plain 
`HashMap::get`, which returns
+   * `Some` for a key explicitly set to `""`), so an explicitly empty or 
whitespace-only
+   * short-bucket value resolves to `Some("")` here and never falls through to 
global -- the
+   * OPPOSITE of Hadoop's own `getPassword`/`lookupPassword` semantics (see
+   * [[hadoopLookupPasswordEffective]]), which treat empty as absent and keep 
trying the next
+   * alias. The ONLY function used to compute native's effective value in
+   * [[s3KeyDivergenceReason]].
+   *
+   * Deliberately does NOT apply `get_config_trimmed`'s `.trim()` here: 
[[s3KeyDivergenceReason]]
+   * trims both this value and Hadoop's effective value together, 
symmetrically, at the point they
+   * are compared, rather than one-sidedly here -- trimming only the native 
side would flag a
+   * spurious divergence for a value neither side's whitespace actually 
changes the behavior of
+   * once each side's own downstream parsing normalizes it (e.g. Hadoop's own 
multi-line
+   * `fs.s3a.aws.credentials.provider` default, which both Hadoop and native 
additionally trim per
+   * comma-separated entry after splitting), while a one-sided trim would make 
an
+   * otherwise-identical default value look diverged for every bucket, never 
claiming natively at
+   * all.
+   */
+  private def nativeShortThenGlobal(
+      hadoopConf: Configuration,
+      bucket: String,
+      baseKey: String): Option[String] = {
+    val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+    Option(hadoopConf.get(shortKey)).orElse(Option(hadoopConf.get(baseKey)))
+  }
+
+  /**
+   * Faithful in-memory replica of `S3AUtils#propagateBucketOptions` 
(`hadoop-aws`), which
+   * `S3AFileSystem#initialize` calls FIRST, before any option or credential 
is read:
+   * `Configuration conf = propagateBucketOptions(originalConf, bucket); ...; 
setConf(conf);` --
+   * every subsequent `conf.get`/`getPassword` call in that filesystem 
instance, including
+   * `${...}` variable substitution, resolves against this propagated view, 
not the original conf.
+   * `hadoop-aws` is not on this module's runtime classpath (see the 
string-literal-keys note
+   * above), so `S3AUtils#propagateBucketOptions` cannot be called directly; 
this reproduces its
+   * logic verbatim using only `hadoop-common`'s `Configuration`:
+   * {{{
+   * public static Configuration propagateBucketOptions(Configuration source, 
String bucket) {
+   *   final String bucketPrefix = FS_S3A_BUCKET_PREFIX + bucket + '.';
+   *   final Configuration dest = new Configuration(source);
+   *   for (Map.Entry<String, String> entry : source) {
+   *     final String key = entry.getKey();
+   *     final String value = entry.getValue(); // the (unexpanded) value
+   *     if (!key.startsWith(bucketPrefix) || bucketPrefix.equals(key)) 
continue;
+   *     final String stripped = key.substring(bucketPrefix.length());
+   *     if (stripped.startsWith("bucket.") || "impl".equals(stripped)) {
+   *       // ignored
+   *     } else {
+   *       final String generic = FS_S3A_PREFIX + stripped;
+   *       dest.set(generic, value, ...); // overwrites any existing global 
value
+   *     }
+   *   }
+   *   return dest;
+   * }
+   * }}}
+   * Note the LONG bucket form (`fs.s3a.bucket.B.fs.s3a.<key>`) folds to an 
unread
+   * `fs.s3a.fs.s3a.<key>` key here too, exactly like the real method -- 
`stripped` already starts
+   * with `fs.s3a.` in that case, so prepending `fs.s3a.` again produces a key 
nothing ever reads.
+   */
+  private def propagateBucketOptions(hadoopConf: Configuration, bucket: 
String): Configuration = {
+    val bucketPrefix = s"fs.s3a.bucket.$bucket."
+    val dest = new Configuration(hadoopConf)
+    hadoopConf.iterator().asScala.foreach { entry =>
+      val key = entry.getKey
+      if (key.startsWith(bucketPrefix) && key != bucketPrefix) {
+        val stripped = key.substring(bucketPrefix.length)
+        if (!stripped.startsWith("bucket.") && stripped != "impl") {
+          dest.set(s"fs.s3a.$stripped", entry.getValue)
+        }
+      }
+    }
+    dest
+  }
+
+  /**
+   * Canonical and deprecated Hadoop S3A encryption-algorithm config keys, 
verified via `javap`
+   * against `hadoop-aws` 3.3.4's `org.apache.hadoop.fs.s3a.Constants`: 
`S3_ENCRYPTION_ALGORITHM =
+   * "fs.s3a.encryption.algorithm"` (canonical) and 
`SERVER_SIDE_ENCRYPTION_ALGORITHM =
+   * "fs.s3a.server-side-encryption-algorithm"` (DEPRECATED -- note the hyphen 
before "algorithm",
+   * unlike the corresponding `*.key` constants below, which both use a `.key` 
suffix).
+   * `hadoop-aws` is NOT on this module's runtime classpath, so these stay 
string literals, same
+   * rationale as [[HadoopCredentialProviderPathKey]].
+   */
+  private val S3EncryptionAlgorithmKey = "fs.s3a.encryption.algorithm"
+  private val DeprecatedS3EncryptionAlgorithmKey = 
"fs.s3a.server-side-encryption-algorithm"
+
+  /**
+   * The exact strings `S3AEncryptionMethods#getMethod` accepts, verified via 
`javap`/CFR against
+   * `hadoop-aws` 3.3.4's `S3AEncryptionMethods` enum: `NONE("")`, 
`SSE_S3("AES256", serverSide =
+   * true, requiresSecret = false)`, `SSE_KMS("SSE-KMS", serverSide = true, 
requiresSecret =
+   * false)`, `SSE_C("SSE-C", serverSide = true, requiresSecret = true)`, 
`CSE_KMS("CSE-KMS",
+   * serverSide = false, requiresSecret = true)`, `CSE_CUSTOM("CSE-CUSTOM", 
serverSide = false,
+   * requiresSecret = true)`. `getMethod` parses case-insensitively
+   * (`values().find(_.getMethod.equalsIgnoreCase(algorithm))`), matched below 
the same way.
+   *
+   * ALLOWLIST, not a blocklist (replaces the former SSE-C-only blocklist): 
only the algorithms S3
+   * decrypts transparently on GET/HEAD given read permission alone, with NO 
extra request header
+   * and NO client-side step, are safe for a native scan that forwards none of 
Hadoop's
+   * `fs.s3a.encryption.*`/`fs.s3a.server-side-encryption*` options --
+   *   - `AES256` (SSE_S3, `serverSide = true`): plain server-side encryption, 
transparent on GET.
+   *   - `SSE-KMS` (SSE_KMS, `serverSide = true`): server-side, KMS-managed 
key, transparent on
+   *     GET given KMS decrypt permission (no header).
+   *   - `DSSE-KMS`: NOT present in this enum on `hadoop-aws` 3.3.4 (confirmed 
by the six values
+   *     listed above) -- `S3AEncryptionMethods.getMethod("DSSE-KMS")` throws
+   *     `IOException("Unknown encryption algorithm DSSE-KMS")` on this 
version, so
+   *     `S3AUtils#buildEncryptionSecrets` (and therefore Hadoop's own reader) 
already fails
+   *     before ever reading such a table under 3.3.4, meaning this string can 
never actually be
+   *     the resolved value on the declared target version -- admitting it 
here is inert there.
+   *     Included anyway, forward-compatible, for a newer `hadoop-aws` on the 
runtime classpath (a
+   *     later Hadoop release; this module has no compile-time `hadoop-aws` 
dependency, see the
+   *     string-literal-keys note above) where DSSE-KMS is a real, dual-layer, 
server-side
+   *     algorithm decrypted transparently on GET the same way SSE-KMS is. 
Every other value
+   *     declines: `SSE-C` (SSE_C is `serverSide = true` in Hadoop's own enum, 
but `requiresSecret
+   *     \= true` -- S3 rejects a GET/HEAD for an SSE-C object outright (400 
Bad Request) unless
+   *     the customer key is resent as a request header on every call, so a 
native scan that never
+   *     learns the key cannot succeed at all, where Hadoop's own reader -- 
whose request factory
+   *     attaches the key -- would), `CSE-KMS`/`CSE-CUSTOM` (`serverSide = 
false`: client-side
+   *     encryption decrypts object bytes locally in the SDK layer, which the 
native Parquet
+   *     reader has no equivalent of -- it would read raw ciphertext), and any 
future/unknown
+   *     value (a value `S3AEncryptionMethods.getMethod` itself would reject 
is certainly not one
+   *     of the three confirmed-transparent algorithms above; declining is the 
only safe default
+   *     for anything this gate cannot positively confirm).
+   */
+  private val AllowedEncryptionAlgorithms: Set[String] = Set("AES256", 
"SSE-KMS", "DSSE-KMS")
+
+  /**
+   * `bucket`'s effective encryption-algorithm key and value under 
`hadoopConf`, or `None` when
+   * neither the canonical nor deprecated key is set anywhere consulted. 
Mirrors
+   * `S3AUtils#buildEncryptionSecrets`'s real resolution order, verified via 
`javap`/CFR
+   * decompilation of `hadoop-aws` 3.3.4's `S3AUtils.class`:
+   * {{{
+   * String algorithm = lookupBucketSecret(bucket, conf, 
"fs.s3a.encryption.algorithm");
+   * if (algorithm == null)
+   *   algorithm = lookupBucketSecret(bucket, conf, 
"fs.s3a.server-side-encryption-algorithm");
+   * if (algorithm == null)
+   *   algorithm = lookupPassword(null, conf, "fs.s3a.encryption.algorithm");
+   * if (algorithm == null)
+   *   algorithm = lookupPassword(null, conf, 
"fs.s3a.server-side-encryption-algorithm");
+   * }}}
+   * i.e. bucket-tier (canonical, then deprecated), THEN global-tier 
(canonical, then deprecated)
+   * -- the two tiers are never interleaved key-by-key, so this must stay two 
explicit bucket-tier
+   * lookups followed by two explicit global-tier lookups, not a single
+   * [[hadoopLookupPasswordEffective]] call per key (which would let an unset 
canonical bucket key
+   * fall through straight to the canonical GLOBAL value ahead of a SET 
deprecated bucket key, the
+   * wrong answer).
+   *
+   * THE FIX for the SSE-C long-bucket-alias gap is entirely inside the bucket 
tier:
+   * `lookupBucketSecret` itself is long-then-short, decompiled from 
`hadoop-aws` 3.3.4's
+   * `S3AUtils.class`:
+   * {{{
+   * // longBucketKey  = fs.s3a.bucket.B.fs.s3a.<key>
+   * String longBucketKey = String.format(BUCKET_PATTERN, bucket, baseKey);
+   * String initialVal = getPassword(conf, longBucketKey, null, null);
+   * // shortBucketKey = fs.s3a.bucket.B.<key>
+   * String shortBucketKey = String.format(BUCKET_PATTERN, bucket, subkey);
+   * // keeps initialVal (the LONG value) if non-empty
+   * return getPassword(conf, shortBucketKey, initialVal, null);
+   * }}}
+   * i.e. the SAME long-bucket-key construction and long-wins-if-nonempty 
semantics as
+   * `S3AUtils#lookupPassword` (see 
[[LookupPasswordConsumer]]/[[hadoopLookupPasswordEffective]])
+   * -- the encryption algorithm is NOT one of the keys that flows through
+   * `S3AUtils#propagateBucketOptions` (which folds an unrelated per-bucket 
LONG form into an
+   * unread key). An earlier version of this function modeled the bucket tier 
as SHORT-only,
+   * documented as "the LONG bucket form is genuinely never consulted for this 
key" -- that
+   * documentation was wrong (this decompilation supersedes it): a bucket 
configured only via
+   * `fs.s3a.bucket.B.fs.s3a.encryption.algorithm=SSE-C` bypassed the SSE-C 
gate entirely, because
+   * Hadoop's own reader DOES read that long form (and picks SSE-C), while 
this function reported
+   * `None` (nothing set) and the allowlist check below never even ran.
+   *
+   * The canonical-vs-deprecated distinction below is frequently moot in 
practice: `hadoop-aws`'s
+   * `S3AFileSystem.addDeprecatedKeys()` statically registers 
`fs.s3a.server-side-encryption-*` as
+   * `Configuration`-level deprecated aliases of `fs.s3a.encryption.*` 
(verified via `javap`), a
+   * registration that lives in a static field on Hadoop's `Configuration` 
class -- process-wide
+   * once `S3AFileSystem`'s class has loaded anywhere in the JVM, which a real 
scan has always
+   * already done by the time this gate runs, since reading the S3 table at 
all requires loading
+   * that class. Once active, `Configuration#get` resolves either literal key 
to the identical
+   * value transparently, making the two-key cascade below redundant (but 
harmless) for that case;
+   * it remains the operative path only when nothing else in the process has 
loaded
+   * `S3AFileSystem` yet.
+   *
+   * ALSO walks the Hadoop-credential-provider (JCEKS) path via 
[[resolveViaCredentialAliases]]
+   * for each of the four lookups below, matching 
`lookupBucketSecret`/`lookupPassword`'s real
+   * per-alias `getPassword` calls (quoted above) exactly: both are 
`getPassword`, not plain
+   * `Configuration#get`, so a bucket storing the algorithm name ONLY in a 
JCEKS keystore is
+   * exactly as real a Hadoop deployment shape for this key as it is for the 
credential keys
+   * [[hadoopLookupPasswordEffective]] already covers -- there is nothing 
algorithm-specific that
+   * makes JCEKS storage implausible here, so an earlier version of this 
function skipping it
+   * (documented at the time as "the algorithm NAME is not 
credential-sensitive data, so storing
+   * it in a keystore is not a realistic Hadoop deployment pattern") was an 
unjustified, narrower
+   * read than Hadoop's own resolver actually performs, under-declining a 
bucket whose algorithm
+   * is keystore-only. [[resolveViaCredentialAliases]]'s Arm B/C split still 
means this is zero
+   * extra I/O for the common case: keystore I/O only happens when a Hadoop 
credential-provider
+   * path is actually configured for the bucket, contained in that function's 
own try/catch.
+   * `bucketTier`/`globalTier` return `Left` (propagated straight through by 
[[orElseTier]]) when
+   * [[resolveViaCredentialAliases]] cannot safely verify a tier at all (an 
S3A-scoped provider
+   * path, or a corrupt/unreadable global keystore) -- correctly 
short-circuiting the whole
+   * cascade with a decline rather than silently falling through to a later 
tier that might look
+   * unset only because the true value was unverifiable.
+   */
+  private def effectiveEncryptionAlgorithm(
+      hadoopConf: Configuration,
+      bucket: String): Either[String, Option[(String, String)]] = {
+    def bucketTier(baseKey: String): Either[String, Option[(String, String)]] 
= {
+      val longKey = s"fs.s3a.bucket.$bucket.$baseKey"
+      val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.")
+      resolveViaCredentialAliases(hadoopConf, bucket, Seq(longKey, shortKey))
+        .map(_.map(baseKey -> _))
+    }
+    def globalTier(baseKey: String): Either[String, Option[(String, String)]] =
+      resolveViaCredentialAliases(hadoopConf, bucket, Seq(baseKey))
+        .map(_.map(baseKey -> _))
+
+    // Short-circuits on Left (unverifiable tier) or Right(Some(_)) 
(resolved); only Right(None)
+    // (tier definitively unset) falls through to `next`, mirroring 
buildEncryptionSecrets's
+    // sequential `if (algorithm == null) algorithm = ...` cascade exactly.
+    def orElseTier(
+        current: Either[String, Option[(String, String)]],
+        next: => Either[String, Option[(String, String)]])
+        : Either[String, Option[(String, String)]] =
+      current match {
+        case Left(reason) => Left(reason)
+        case Right(Some(value)) => Right(Some(value))
+        case Right(None) => next
+      }
+
+    orElseTier(
+      bucketTier(S3EncryptionAlgorithmKey),
+      orElseTier(
+        bucketTier(DeprecatedS3EncryptionAlgorithmKey),
+        orElseTier(
+          globalTier(S3EncryptionAlgorithmKey),
+          globalTier(DeprecatedS3EncryptionAlgorithmKey))))
+  }
+
+  private def unsupportedEncryptionAlgorithmDeclineReason(
+      bucket: String,
+      algorithmKey: String,
+      algorithm: String): String =
+    s"Native Delta scan does not support $algorithmKey=$algorithm for $bucket 
" +
+      "(the native S3 client only supports unencrypted objects and S3's 
transparent " +
+      "server-side algorithms -- AES256/SSE-S3, SSE-KMS, and DSSE-KMS decrypt 
on GET/HEAD given " +
+      "read permission alone, with no extra request header; SSE-C additionally 
requires the " +
+      "customer-provided key resent as a header on every GET/HEAD request, 
which the native S3 " +
+      "client's extract_s3_config_options never forwards, and 
CSE-KMS/CSE-CUSTOM decrypt object " +
+      "bytes client-side, a layer the native Parquet reader does not have -- 
any of these would " +
+      "fail outright or silently read ciphertext where Hadoop's own reader 
succeeds)"
+
+  /**
+   * First reason any bucket among `uris` is configured for an encryption 
algorithm the native S3
+   * client cannot safely read, or `None` when claimable. Allowlist-based (see
+   * [[AllowedEncryptionAlgorithms]]): only `AES256`/`SSE-KMS`/`DSSE-KMS` (and 
unset/empty) pass;
+   * every other resolved value -- `SSE-C`, `CSE-KMS`, `CSE-CUSTOM`, or any 
unrecognized future
+   * algorithm string -- declines. Deliberately NOT a blocklist keyed on 
`SSE-C` alone: an
+   * allowlist is safe by construction against a Hadoop release adding a new 
encryption method
+   * this gate has never heard of, where a blocklist would silently admit it. 
Never interpolates a
+   * resolved key value, only key names, the bucket, and the (non-secret) 
algorithm name. Declines
+   * on a `Left` from [[effectiveEncryptionAlgorithm]] too (an unverifiable 
credential-provider
+   * arm, e.g. an S3A-scoped provider path or a corrupt/unreadable global 
keystore) -- the
+   * algorithm cannot be ruled safe when it cannot be read at all.
+   */
+  private[delta] def unsupportedEncryptionAlgorithmReason(
+      hadoopConf: Configuration,
+      uris: Seq[URI]): Option[String] = {
+    val buckets = uris.flatMap(s3Bucket).distinct
+    buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+      if (declined.isDefined) {
+        declined
+      } else {
+        effectiveEncryptionAlgorithm(hadoopConf, bucket) match {
+          case Left(reason) => Some(reason)
+          case Right(None) => None
+          case Right(Some((key, value))) =>
+            if 
(!AllowedEncryptionAlgorithms.exists(_.equalsIgnoreCase(value))) {
+              Some(unsupportedEncryptionAlgorithmDeclineReason(bucket, key, 
value))
+            } else {
+              None
+            }
+        }
+      }
+    }
+  }
+
+  /**
+   * Canonical Hadoop S3A HTTP-proxy host config key, verified via CFR 
decompilation of
+   * `hadoop-aws` 3.3.4's `S3AUtils.class` (`initProxySupport`):
+   * {{{
+   * String proxyHost = conf.getTrimmed("fs.s3a.proxy.host", "");
+   * int proxyPort = conf.getInt("fs.s3a.proxy.port", -1);
+   * if (!proxyHost.isEmpty()) {
+   *   ...
+   *   String proxyUsername =
+   *       S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.username", 
null, null);
+   *   String proxyPassword =
+   *       S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.password", 
null, null);
+   *   ...
+   * }
+   * }}}
+   * `fs.s3a.proxy.host`/`fs.s3a.proxy.port` resolve via a PLAIN, 
non-bucket-scoped, non-JCEKS
+   * `Configuration#getTrimmed`/`getInt` call -- NOT `lookupPassword` -- 
against whatever conf
+   * `S3AFileSystem#initialize` already ran through `propagateBucketOptions` 
before
+   * `createAwsConf`/`initProxySupport` ever runs; only the SIBLING 
`fs.s3a.proxy.username`/
+   * `fs.s3a.proxy.password` keys go through `lookupPassword` (bucket 
long/short/global,
+   * JCEKS-aware). So the host is bucket-aware only via 
`propagateBucketOptions`'s short-bucket-
+   * form folding, never the long-bucket form, and never a credential-provider 
read -- the SAME
+   * shape as `endpoint`/`path.style.access` ([[PropagatedOptionConsumer]], see
+   * [[S3ConfigKeyConsumers]]'s doc), not the credential family. `hadoop-aws` 
3.4.x (the Spark 4.x
+   * profiles' version) moves this code to 
`AWSClientConfig#createProxyConfiguration`/
+   * `#createAsyncProxyConfiguration` but keeps the exact same reads, verified 
via `javap` against
+   * 3.4.2: `conf.getTrimmed("fs.s3a.proxy.host", "")` for the host, 
`S3AUtils.lookupPassword` for
+   * username/password only.
+   *
+   * [[proxyGateReason]] therefore resolves the host EXACTLY like its real 
consumer -- plain
+   * `Configuration#getTrimmed` on the [[propagateBucketOptions]] result, no 
provider arms, no
+   * [[ClearTextFallbackKey]] handling -- rather than through the wider 
`lookupPassword` cascade
+   * an earlier version reused here. The wider cascade was wrong in BOTH 
directions for this key:
+   * with only the GLOBAL Hadoop provider path set and 
[[ClearTextFallbackKey]] false,
+   * `getPassword` hides a plaintext host that `getTrimmed` serves to Hadoop 
anyway (a missed
+   * decline, the exact bypass this gate exists to close -- native has NO 
HTTP-proxy support of
+   * any kind, no `fs.s3a.proxy.*` key is read anywhere in `s3.rs`); and an 
S3A-scoped provider
+   * path or a lone long-form bucket alias declined a bucket whose real 
consumer can never see a
+   * host from either source (pure over-refusal -- no keystore can supply the 
host to a plain
+   * `getTrimmed`, and the long form folds into the unread 
`fs.s3a.fs.s3a.proxy.host`).
+   */
+  private val S3ProxyHostKey = "fs.s3a.proxy.host"
+
+  private def unsupportedProxyReason(bucket: String, key: String): String =
+    s"Native Delta scan does not support $key configured for $bucket (the 
native S3 client has " +
+      "no HTTP proxy support at all -- no fs.s3a.proxy.* key is read anywhere 
in its object " +
+      "store layer -- so a claimed scan would connect to S3 directly instead 
of routing through " +
+      "the configured proxy, either bypassing an egress/network-segmentation 
policy or simply " +
+      "failing to reach the endpoint)"
+
+  /**
+   * First reason any bucket among `uris` has an HTTP proxy configured via 
[[S3ProxyHostKey]], or
+   * `None` when claimable. Reads the host EXACTLY like its real consumer (see
+   * [[S3ProxyHostKey]]'s doc): plain `Configuration#getTrimmed` on the 
[[propagateBucketOptions]]
+   * result, so this gate is always zero-I/O -- no credential provider is ever 
consulted for the
+   * host, because none ever supplies it to Hadoop either. Ordered alongside
+   * [[unsupportedEncryptionAlgorithmReason]] among the conf-only gates, ahead 
of
+   * [[s3ConfigDivergenceReason]]. The try/catch guards `Configuration#get`'s
+   * `IllegalStateException` on a `${...}` substitution cycle, same as 
[[s3KeyDivergenceReason]].
+   * Never interpolates a resolved value: the proxy HOST is not secret, but 
naming it here would
+   * be a strange place to first surface it, and proxy CREDENTIALS
+   * (`fs.s3a.proxy.username`/`fs.s3a.proxy.password`, not read by this gate 
at all -- the whole
+   * point of gating on the host is that a non-empty host declines before any 
proxy credential
+   * would ever need to be forwarded) must never appear in a decline reason 
regardless.
+   */
+  private[delta] def proxyGateReason(
+      hadoopConf: Configuration,
+      uris: Seq[URI],
+      propagatedConfCache: MutableMap[String, Configuration] = 
MutableMap.empty)
+      : Option[String] = {
+    val buckets = uris.flatMap(s3Bucket).distinct
+    buckets.foldLeft(Option.empty[String]) { (declined, bucket) =>
+      if (declined.isDefined) {
+        declined
+      } else {
+        try {
+          val propagatedConf =
+            propagatedConfCache.getOrElseUpdate(
+              bucket,
+              propagateBucketOptions(hadoopConf, bucket))
+          if (propagatedConf.getTrimmed(S3ProxyHostKey, "").nonEmpty) {
+            Some(unsupportedProxyReason(bucket, S3ProxyHostKey))
+          } else {
+            None
+          }
+        } catch {
+          case e @ (_: IOException | _: RuntimeException) =>
+            Some(unverifiableValueReason(bucket, S3ProxyHostKey, e))
+        }
+      }
+    }
+  }
+
+  /**
+   * `baseKey`'s long, short, then global per-bucket aliases, in Hadoop's own 
resolution order.
+   */
+  private def longThenShortThenGlobalAliases(bucket: String, baseKey: String): 
Seq[String] = {
+    val suffix = baseKey.stripPrefix("fs.s3a.")
+    Seq(s"fs.s3a.bucket.$bucket.fs.s3a.$suffix", 
s"fs.s3a.bucket.$bucket.$suffix", baseKey)
+  }
+
+  private def s3aScopedProviderPathReason(bucket: String, providerPathKey: 
String): String =
+    "Native Delta scan cannot forward Hadoop credential-provider aliases for " 
+
+      s"$bucket ($providerPathKey configures an S3A-scoped Hadoop credential 
provider that " +
+      "Configuration#getPassword does not consult, so the native S3 client's 
credentials " +
+      "cannot be verified)"
+
+  private def unverifiableCredentialProviderReason(bucket: String, error: 
Throwable): String =
+    "Native Delta scan cannot verify Hadoop credential-provider aliases for " +
+      s"$bucket (reading $HadoopCredentialProviderPathKey raised " +
+      s"${error.getClass.getName}), declining rather than risk missing 
credentials"
+
+  /**
+   * The three-way Hadoop credential-provider-path precheck shared by every 
`getPassword`-based
+   * resolution below, factored out of what used to be 
[[hadoopLookupPasswordEffective]]'s own

Review Comment:
   A few of the doc comments in this file describe how the code got here rather 
than what it does. This one refers to `hadoopLookupPasswordEffective`, and 
there are similar ones at 1139 ("Generalizes what used to be"), 1415 
("previously relied on `s3ConfigDivergenceReason` running first"), and 1489 
("the variable-reference precheck this used to run ahead of the class-support 
check is gone"). `DeltaScanContribSuite.scala:37` has one too, referring to 
"core's now-deleted extension-SPI".
   
   Some of those symbols are not in the tree, so a reader who greps for them 
comes up empty, and the commits are squashed so the history is not recoverable 
there either. Could these be trimmed down to what the code does now, with any 
load-bearing context moved to the PR description?



##########
native/core/src/parquet/schema_adapter.rs:
##########
@@ -76,6 +77,583 @@ fn schema_has_field_ids(schema: &SchemaRef) -> bool {
     schema.fields().iter().any(|f| parse_field_id(f).is_some())
 }
 
+// 
---------------------------------------------------------------------------------------------
+// JVM-shipped case tables: reproduce the PLANNING JVM's 
`String.toLowerCase(Locale.ROOT)`,
+// which Spark's Parquet footer field matching is built on.
+//
+// The data arrives on `NativeScanCommon` (populated by `JvmCaseTables.scala` 
when
+// case_sensitive = false), generated from the very JVM that plans the query, 
so the native
+// matcher is correct BY CONSTRUCTION for whatever JDK runs Spark. The one 
contextual mapping
+// (`Locale.ROOT` has exactly one: Greek capital sigma U+03A3) cannot be a 
per-codepoint table
+// entry, so its condition is ported as an algorithm over a shipped 
per-codepoint
+// classification -- see `JvmCaseTables::lowercase`.
+// 
---------------------------------------------------------------------------------------------
+
+// Sigma-scan classes: the wire contract shared with `JvmCaseTables.scala`. 
The classes are
+// the UAX#29-style word-break classes (ALetter, Numeric, MidLetter, MidNum, 
MidNumLet,
+// Extend, Format) as the PLANNING JVM's legacy break iterator actually 
realizes them --
+// probed per codepoint from `BreakIterator.isBoundary` on the JVM side -- 
plus classes for
+// its pre-UAX#29 extensions (the danda and the supplementary-plane behaviors 
of its UTF-16
+// DFA), with cased variants split out via the JDK's `isCased`. Any codepoint 
outside every
+// shipped range -- and any class value this build does not know -- is a word 
boundary: the
+// sigma context scan stops there, which is also the safe reading for class 
values added by a
+// NEWER JVM-side generator.
+const CLASS_ALETTER_CASED: u8 = 1;
+const CLASS_ALETTER: u8 = 2;
+const CLASS_NUMERIC: u8 = 3;
+const CLASS_MID_LETTER: u8 = 4;
+const CLASS_MID_NUM: u8 = 5;
+const CLASS_MID_NUM_LET: u8 = 6;
+/// Cased supplementary char: attaches to the preceding word and closes it 
(and forms a word
+/// of its own at raw text start).
+const CLASS_SUPP_CASED: u8 = 7;
+/// U+0964/U+0965: word-terminal, chains only into digits.
+const CLASS_DANDA: u8 = 8;
+/// U+0345, the one cased combining mark: cased only when its run is attached 
to a word.
+const CLASS_EXTEND_CASED: u8 = 9;
+/// Cased digit-base (Nl Roman numerals): joins like CLASS_ALETTER_CASED when 
reached
+/// directly, but bridges only mid-num punctuation, never mid-letter.
+const CLASS_NUMERIC_CASED: u8 = 10;
+/// Non-cased Mn/Me marks: riders that attach only to genuine letter/digit 
bases.
+const CLASS_EXTEND: u8 = 11;
+/// Word-forming non-cased supplementary letter: a genuine letter-base that 
closes the word
+/// immediately after itself.
+const CLASS_SUPP_LETTER: u8 = 12;
+/// Cf format characters: fully transparent (WB4-style) -- deleted from the 
sequence before
+/// the scans run, so a pure-format rider chain bridges mid punctuation 
("AΣ-<ZWJ>b" is one
+/// word exactly like "AΣ-b").
+const CLASS_FORMAT: u8 = 13;
+/// Supplementary chars that attach to the preceding word but never form one 
themselves
+/// (supplementary combining marks, tag characters): a cased mark riding on 
one belongs to
+/// the sigma's word only when the run hangs off a real base 
(`supp_mn_anchor`).
+const CLASS_SUPP_MN: u8 = 14;
+/// Word-forming supplementary digit: like CLASS_SUPP_LETTER except a riding 
cased mark
+/// carries only across mid-num (digit-context) punctuation, never mid-letter.
+const CLASS_SUPP_NUM: u8 = 15;
+/// Not on the wire: the absence of a class.
+const CLASS_BOUNDARY: u8 = 0;
+
+const CAPITAL_SIGMA: char = '\u{03A3}';
+const SMALL_SIGMA: char = '\u{03C3}';
+const SMALL_FINAL_SIGMA: char = '\u{03C2}';
+
+/// What a supplementary-mark run ultimately hangs off (see `supp_mn_anchor`).
+#[derive(PartialEq, Eq, Clone, Copy)]
+enum SuppMnAnchor {
+    None,
+    Letter,
+    Digit,
+}
+
+/// The planning JVM's case data, parsed once per scan from `NativeScanCommon` 
and attached to
+/// [`SparkParquetOptions`]. Two tables:
+///
+///   - `lower`: every codepoint the JVM lowercases non-identically, with its 
full (possibly
+///     multi-char, e.g. U+0130 -> "i" + U+0307) replacement; codepoints 
absent here lowercase
+///     to themselves;
+///   - `class_ranges`: sorted, disjoint `(start, end, class)` codepoint 
ranges holding the
+///     word-break classification the JVM probed from its own `BreakIterator`.
+///
+/// `lowercase` applies Java's algorithm over that data: per codepoint, U+03A3 
takes its
+/// contextual final/non-final form via the ported `isFinalCased` condition -- 
word-boundary
+/// based (the JDK's legacy break-iterator word rules), NOT the 
Unicode-standard Final_Sigma
+/// case-ignorable skip, so e.g. "A1Σ" lowers to "a1ς" -- and every other 
codepoint takes its
+/// table replacement. `JvmCaseTables.mirrorLowercase` on the Scala side is 
the line-for-line
+/// mirror of this function over the same generated data; the JVM-side parity 
suite proves the
+/// pair equal to the running JDK's `String.toLowerCase(Locale.ROOT)` across 
the full codepoint
+/// space (calibrated to zero mismatches on JDK 17, 21, and 25).
+#[derive(Debug)]
+pub struct JvmCaseTables {
+    /// Non-identity lowercase mappings: codepoint -> full replacement string.
+    lower: HashMap<char, String>,
+    /// Sorted, disjoint (start, end, class) inclusive codepoint ranges for 
the sigma scan.
+    class_ranges: Vec<(u32, u32, u8)>,
+    /// Precomputed content hash so `SparkParquetOptions`'s derived `Hash` 
stays cheap.
+    fingerprint: u64,
+}
+
+impl PartialEq for JvmCaseTables {
+    fn eq(&self, other: &Self) -> bool {
+        self.fingerprint == other.fingerprint
+            && self.class_ranges == other.class_ranges
+            && self.lower == other.lower
+    }
+}
+
+impl Eq for JvmCaseTables {}
+
+impl Hash for JvmCaseTables {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        // Equal contents always produce the equal (deterministically 
computed) fingerprint,
+        // so hashing only the fingerprint is consistent with `PartialEq`.
+        state.write_u64(self.fingerprint);
+    }
+}
+
+fn is_letter_base(cls: u8) -> bool {
+    cls == CLASS_ALETTER_CASED
+        || cls == CLASS_ALETTER
+        || cls == CLASS_SUPP_CASED
+        || cls == CLASS_SUPP_LETTER
+}
+
+fn is_digit_base(cls: u8) -> bool {
+    cls == CLASS_NUMERIC || cls == CLASS_NUMERIC_CASED
+}
+
+impl JvmCaseTables {
+    /// Parse the proto representation: `lower_cp`/`lower_repl` are 
index-aligned, and
+    /// `class_ranges` holds (start, end, class) triples. Malformed input 
(length mismatch,
+    /// trailing partial triple, out-of-range codepoints) is dropped 
entry-by-entry rather
+    /// than rejected: every dropped entry degrades one codepoint to 
identity/boundary
+    /// behavior instead of failing the scan.
+    pub fn from_proto(lower_cp: &[u32], lower_repl: &[String], class_ranges: 
&[u32]) -> Self {
+        let mut lower = HashMap::with_capacity(lower_cp.len());
+        for (cp, repl) in lower_cp.iter().zip(lower_repl.iter()) {
+            if let Some(c) = char::from_u32(*cp) {
+                lower.insert(c, repl.clone());
+            }
+        }
+        let ranges: Vec<(u32, u32, u8)> = class_ranges
+            .as_chunks::<3>()
+            .0
+            .iter()
+            .filter(|t| t[0] <= t[1] && t[1] <= 0x10FFFF && 
u8::try_from(t[2]).is_ok())
+            .map(|t| (t[0], t[1], t[2] as u8))
+            .collect();
+
+        let mut hasher = DefaultHasher::new();
+        for (start, end, class) in &ranges {
+            hasher.write_u32(*start);
+            hasher.write_u32(*end);
+            hasher.write_u8(*class);
+        }
+        let mut lower_sorted: Vec<(&char, &String)> = lower.iter().collect();
+        lower_sorted.sort_by_key(|(c, _)| **c);
+        for (c, repl) in lower_sorted {
+            hasher.write_u32(*c as u32);
+            hasher.write(repl.as_bytes());
+        }
+
+        Self {
+            lower,
+            class_ranges: ranges,
+            fingerprint: hasher.finish(),
+        }
+    }
+
+    /// Sigma-scan class of `c`; `CLASS_BOUNDARY` when no shipped range covers 
it.
+    fn class_of(&self, c: char) -> u8 {
+        let cp = c as u32;
+        let mut lo = 0usize;
+        let mut hi = self.class_ranges.len();
+        while lo < hi {
+            let mid = (lo + hi) / 2;
+            let (start, end, class) = self.class_ranges[mid];
+            if cp < start {
+                hi = mid;
+            } else if cp > end {
+                lo = mid + 1;
+            } else {
+                return class;
+            }
+        }
+        CLASS_BOUNDARY
+    }
+
+    /// First position at or beyond `start` (stepping by `step`, i.e. -1 
backward / +1
+    /// forward) whose class is not CLASS_EXTEND. Returns `None` if the scan 
runs off the
+    /// array without finding one.
+    fn skip_extends(&self, cps: &[char], start: isize, step: isize) -> 
Option<usize> {
+        let mut k = start;
+        while k >= 0 && (k as usize) < cps.len() {
+            if self.class_of(cps[k as usize]) != CLASS_EXTEND {
+                return Some(k as usize);
+            }
+            k += step;
+        }
+        None
+    }
+
+    /// As [`Self::skip_extends`], but also skips CLASS_EXTEND_CASED, 
reporting whether one
+    /// was walked: a cased mark (U+0345) crossed while looking for a base is 
itself cased
+    /// whenever the landing validates the run.
+    fn skip_extends_tracking_cased(
+        &self,
+        cps: &[char],
+        start: isize,
+        step: isize,
+    ) -> (Option<usize>, bool) {
+        let mut k = start;
+        let mut saw_cased = false;
+        while k >= 0 && (k as usize) < cps.len() {
+            let cls = self.class_of(cps[k as usize]);
+            if cls != CLASS_EXTEND && cls != CLASS_EXTEND_CASED {
+                return (Some(k as usize), saw_cased);
+            }
+            if cls == CLASS_EXTEND_CASED {
+                saw_cased = true;
+            }
+            k += step;
+        }
+        (None, saw_cased)
+    }
+
+    /// What the supplementary-mark run at `k` (CLASS_SUPP_MN) ultimately 
hangs off, walking
+    /// down through further marks and supplementary chars: a letter-flavored 
base, a
+    /// digit-flavored base, or nothing word-forming. A cased mark riding the 
run belongs to
+    /// the sigma's word only per this anchor.
+    fn supp_mn_anchor(&self, cps: &[char], k: usize) -> SuppMnAnchor {
+        let mut m = k as isize - 1;
+        while m >= 0 {
+            let cls = self.class_of(cps[m as usize]);
+            if cls != CLASS_SUPP_MN && cls != CLASS_EXTEND && cls != 
CLASS_EXTEND_CASED {
+                break;
+            }
+            m -= 1;
+        }
+        if m < 0 {
+            return SuppMnAnchor::None;
+        }
+        match self.class_of(cps[m as usize]) {
+            CLASS_ALETTER_CASED | CLASS_ALETTER | CLASS_SUPP_CASED | 
CLASS_SUPP_LETTER => {
+                SuppMnAnchor::Letter
+            }
+            CLASS_NUMERIC | CLASS_NUMERIC_CASED | CLASS_SUPP_NUM => 
SuppMnAnchor::Digit,
+            _ => SuppMnAnchor::None,
+        }
+    }
+
+    /// Backward half of the ported `isFinalCased`: is there a cased letter 
before position
+    /// `i` within the sigma's word? Runs over the FORMAT-FILTERED sequence; 
`leading_format`
+    /// says whether format chars were filtered off the raw text start.
+    fn scan_back_finds_cased(&self, cps: &[char], i: usize, leading_format: 
bool) -> bool {
+        let mut last_letter = true; // the sigma itself is a letter
+        let mut j = i as isize - 1;
+        while j >= 0 {
+            match self.class_of(cps[j as usize]) {
+                CLASS_ALETTER_CASED | CLASS_NUMERIC_CASED => return true,
+                CLASS_ALETTER => {
+                    last_letter = true;
+                    j -= 1;
+                }
+                CLASS_NUMERIC => {
+                    last_letter = false;
+                    j -= 1;
+                }
+                CLASS_EXTEND => {
+                    // Non-cased marks attach only to a real base below them; 
anything else
+                    // (mid punctuation, danda, boundary, text start) leaves 
the run
+                    // unattached.
+                    let Some(k) = self.skip_extends(cps, j, -1) else {
+                        return false;
+                    };
+                    let b = self.class_of(cps[k]);
+                    let is_continuer = b == CLASS_ALETTER_CASED
+                        || b == CLASS_NUMERIC_CASED
+                        || b == CLASS_NUMERIC
+                        || b == CLASS_EXTEND_CASED
+                        || b == CLASS_ALETTER
+                        || b == CLASS_SUPP_CASED
+                        || b == CLASS_SUPP_LETTER
+                        || b == CLASS_SUPP_NUM;
+                    if !is_continuer {
+                        return false;
+                    }
+                    j = k as isize;
+                }
+                CLASS_SUPP_CASED => {
+                    // Closes the preceding word, so the scan stops -- except 
at RAW text
+                    // start (no filtered-out leading format chars), where the 
DFA keeps it
+                    // joined to what follows.
+                    return j == 0 && !leading_format;
+                }
+                CLASS_SUPP_LETTER | CLASS_SUPP_MN | CLASS_SUPP_NUM => {
+                    // Attach/close and never themselves cased; nothing beyond 
is reachable.
+                    return false;
+                }
+                CLASS_EXTEND_CASED => {
+                    // Cased combining mark (U+0345): cased when its run hangs 
off a base --
+                    // a BMP letter/digit, a word-forming supplementary char 
(which closes a
+                    // word right below the mark, merging the mark into the 
sigma's
+                    // segment), or an ANCHORED supplementary mark.
+                    let (Some(k), _) = self.skip_extends_tracking_cased(cps, j 
- 1, -1) else {
+                        return false;
+                    };
+                    let b = self.class_of(cps[k]);
+                    if b == CLASS_ALETTER_CASED
+                        || b == CLASS_NUMERIC
+                        || b == CLASS_NUMERIC_CASED
+                        || b == CLASS_ALETTER
+                        || b == CLASS_SUPP_CASED
+                        || b == CLASS_SUPP_LETTER
+                        || b == CLASS_SUPP_NUM
+                    {
+                        return true;
+                    }
+                    if b == CLASS_SUPP_MN {
+                        return self.supp_mn_anchor(cps, k) != 
SuppMnAnchor::None;
+                    }
+                    return false;
+                }
+                CLASS_DANDA => {
+                    // Backward across a danda: the word part before it must 
end in letters
+                    // (grammar: letters, optional danda, then number+word 
chains) -- or
+                    // carry a riding cased mark on a word-forming base, or be 
a cased
+                    // supplementary char at text start -- and the danda 
itself chains only
+                    // into digits after it.
+                    if last_letter {
+                        return false;
+                    }
+                    let (Some(k), saw_cased_mark) =
+                        self.skip_extends_tracking_cased(cps, j - 1, -1)
+                    else {
+                        return false;
+                    };
+                    let b = self.class_of(cps[k]);
+                    if b == CLASS_ALETTER_CASED {
+                        return true;
+                    }
+                    if b == CLASS_SUPP_CASED {
+                        return saw_cased_mark || (k == 0 && !leading_format);
+                    }
+                    if b == CLASS_SUPP_LETTER {
+                        return saw_cased_mark;
+                    }
+                    if b == CLASS_SUPP_MN {
+                        return saw_cased_mark
+                            && self.supp_mn_anchor(cps, k) == 
SuppMnAnchor::Letter;
+                    }
+                    if b != CLASS_ALETTER {
+                        return false;
+                    }
+                    if saw_cased_mark {
+                        return true;
+                    }
+                    last_letter = true;
+                    j = k as isize;
+                }
+                cls @ (CLASS_MID_LETTER | CLASS_MID_NUM | CLASS_MID_NUM_LET) 
=> {
+                    // `<mid-letter><let>` / `<mid-num><digit>` require a 
genuine
+                    // letter/digit base before the punctuation; scanning 
backward
+                    // legitimately walks marks-then-base (marks trail their 
base). A cased
+                    // mark walked over rides whatever the punctuation hangs 
off, including
+                    // a context-matching anchored supplementary mark or 
supplementary
+                    // digit.
+                    let mw_ok = cls == CLASS_MID_LETTER || cls == 
CLASS_MID_NUM_LET;
+                    let mn_ok = cls == CLASS_MID_NUM || cls == 
CLASS_MID_NUM_LET;
+                    let (Some(real_pos), saw_cased_mark) =
+                        self.skip_extends_tracking_cased(cps, j - 1, -1)
+                    else {
+                        return false;
+                    };
+                    let b = self.class_of(cps[real_pos]);
+                    if last_letter
+                        && mw_ok
+                        && saw_cased_mark
+                        && b == CLASS_SUPP_MN
+                        && self.supp_mn_anchor(cps, real_pos) == 
SuppMnAnchor::Letter
+                    {
+                        return true;
+                    }
+                    if !last_letter
+                        && mn_ok
+                        && saw_cased_mark
+                        && (b == CLASS_SUPP_NUM
+                            || (b == CLASS_SUPP_MN
+                                && self.supp_mn_anchor(cps, real_pos) == 
SuppMnAnchor::Digit))
+                    {
+                        return true;
+                    }
+                    let bridge_valid = (last_letter && mw_ok && 
is_letter_base(b))
+                        || (!last_letter && mn_ok && is_digit_base(b));
+                    if !bridge_valid {
+                        return false;
+                    }
+                    if saw_cased_mark {
+                        return true;
+                    }
+                    j = real_pos as isize;
+                }
+                _ => return false,
+            }
+        }
+        false
+    }
+
+    /// Forward half of the ported `isFinalCased`: is there a cased letter 
after position `i`
+    /// within the sigma's word? Runs over the FORMAT-FILTERED sequence.
+    fn scan_fwd_finds_cased(&self, cps: &[char], i: usize) -> bool {
+        let mut last_letter = true;
+        let mut j = i + 1;
+        while j < cps.len() {
+            match self.class_of(cps[j]) {
+                CLASS_ALETTER_CASED | CLASS_NUMERIC_CASED => return true,
+                CLASS_ALETTER => {
+                    last_letter = true;
+                    j += 1;
+                }
+                CLASS_NUMERIC => {
+                    last_letter = false;
+                    j += 1;
+                }
+                CLASS_EXTEND => {
+                    // A mark run trailing the anchor is properly attached in 
text order, so
+                    // the run stays open past it, including onto mid 
punctuation on its far
+                    // side.
+                    let Some(k) = self.skip_extends(cps, j as isize, 1) else {
+                        return false;
+                    };
+                    let b = self.class_of(cps[k]);
+                    let is_continuer = b == CLASS_ALETTER_CASED
+                        || b == CLASS_NUMERIC_CASED
+                        || b == CLASS_NUMERIC
+                        || b == CLASS_EXTEND_CASED
+                        || b == CLASS_ALETTER
+                        || b == CLASS_SUPP_CASED
+                        || b == CLASS_SUPP_LETTER
+                        || b == CLASS_SUPP_MN
+                        || b == CLASS_SUPP_NUM
+                        || b == CLASS_DANDA
+                        || b == CLASS_MID_LETTER
+                        || b == CLASS_MID_NUM
+                        || b == CLASS_MID_NUM_LET;
+                    if !is_continuer {
+                        return false;
+                    }
+                    j = k;
+                }
+                CLASS_SUPP_CASED | CLASS_EXTEND_CASED => {
+                    // Attaches to the current word, so the scan sees it 
(cased).
+                    return true;
+                }
+                CLASS_SUPP_LETTER | CLASS_SUPP_MN | CLASS_SUPP_NUM => {
+                    // Attach to the current word and close it; never 
themselves cased, and
+                    // nothing beyond is reachable.
+                    return false;
+                }
+                // The danda attaches only to a word part that ends in letters 
(reached
+                // after digits the word is already closed) and continues only 
into a digit
+                // -- unless that digit is itself cased (a Roman numeral), 
which resolves
+                // the scan immediately.
+                CLASS_DANDA if !last_letter => return false,
+                CLASS_DANDA
+                    if j + 1 < cps.len() && self.class_of(cps[j + 1]) == 
CLASS_NUMERIC_CASED =>
+                {
+                    return true;
+                }
+                CLASS_DANDA if j + 1 < cps.len() && self.class_of(cps[j + 1]) 
== CLASS_NUMERIC => {
+                    last_letter = false;
+                    j += 2;
+                }
+                cls @ (CLASS_MID_LETTER | CLASS_MID_NUM | CLASS_MID_NUM_LET) 
=> {
+                    // `<mid-letter><let>` / `<mid-num><digit>` require a 
genuine
+                    // letter/digit base IMMEDIATELY after the punctuation -- 
unlike the
+                    // backward scan, marks here are never skipped past: a 
mark directly
+                    // after the punctuation is attached to the punctuation, 
not a base, so
+                    // it blocks the bridge. (Format chars are already 
filtered out, which
+                    // is what lets "AΣ-<ZWJ>b" bridge exactly like "AΣ-b".)
+                    let mw_ok = cls == CLASS_MID_LETTER || cls == 
CLASS_MID_NUM_LET;
+                    let mn_ok = cls == CLASS_MID_NUM || cls == 
CLASS_MID_NUM_LET;
+                    if j + 1 >= cps.len() {
+                        return false;
+                    }
+                    let b = self.class_of(cps[j + 1]);
+                    if (last_letter && mw_ok && is_letter_base(b))
+                        || (!last_letter && mn_ok && is_digit_base(b))
+                    {
+                        j += 1;
+                    } else {
+                        return false;
+                    }
+                }
+                _ => return false,
+            }
+        }
+        false
+    }
+
+    /// Lowercase `s` the way the planning JVM's 
`String.toLowerCase(Locale.ROOT)` does.
+    pub fn lowercase(&self, s: &str) -> String {
+        let raw: Vec<char> = s.chars().collect();
+        // Built lazily on the first sigma: the format-filtered sequence the 
scans run over
+        // (WB4-style: the legacy break iterator's `<ignore>` class loops on 
every DFA
+        // state), the raw->filtered index map, and whether format chars led 
the raw text.
+        let mut filtered: Option<(Vec<char>, Vec<usize>, bool)> = None;
+        let mut out = String::with_capacity(s.len());
+        for (i, &c) in raw.iter().enumerate() {
+            if c == CAPITAL_SIGMA {
+                // The condition consults the ORIGINAL neighbors, exactly as 
the JDK scans
+                // `src`, not the partially-lowered output. The 
final/non-final target chars
+                // are Unicode-stable (pinned in `ConditionalSpecialCasing`'s 
entry table).
+                let (f, idx, leading_format) = filtered.get_or_insert_with(|| {
+                    let mut f = Vec::with_capacity(raw.len());
+                    let mut idx = vec![0usize; raw.len()];
+                    for (k, &rc) in raw.iter().enumerate() {
+                        idx[k] = f.len();
+                        if self.class_of(rc) != CLASS_FORMAT {
+                            f.push(rc);
+                        }
+                    }
+                    let leading_format = self.class_of(raw[0]) == CLASS_FORMAT;
+                    (f, idx, leading_format)
+                });
+                let fi = idx[i];
+                let is_final = self.scan_back_finds_cased(f, fi, 
*leading_format)
+                    && !self.scan_fwd_finds_cased(f, fi);
+                out.push(if is_final {
+                    SMALL_FINAL_SIGMA
+                } else {
+                    SMALL_SIGMA
+                });
+            } else if let Some(repl) = self.lower.get(&c) {
+                out.push_str(repl);
+            } else {
+                out.push(c);
+            }
+        }
+        out
+    }
+}
+
+/// Lowercase `s` for case-insensitive field matching. With tables (populated 
whenever
+/// `case_sensitive = false`, shared by the core Parquet scan and the Delta 
contrib scan) this
+/// reproduces the planning JVM's `String.toLowerCase(Locale.ROOT)` exactly.
+///
+/// Without tables, fall back to Rust's `str::to_lowercase`, which agrees with 
Java on all
+/// simple mappings and differs only where the two Unicode snapshots or the 
sigma context
+/// diverge. This is a real, live path, not just a defensive default: the 
Iceberg native scan
+/// (`SparkPhysicalExprAdapterFactory::new(_, None)`) defaults `case_sensitive 
= false` and
+/// always reaches this fallback for its schema name remap, alongside
+/// `parquet_convert_struct_to_struct`'s general struct-cast matching and 
Rust-only unit tests
+/// that construct `SparkParquetOptions` directly.
+pub(crate) fn java_lowercase(s: &str, tables: Option<&JvmCaseTables>) -> 
String {

Review Comment:
   This is the one I would most like to see addressed before merge.
   
   `names_equal_ignore_case_java` replaces `eq_ignore_ascii_case` throughout 
the scan path, and it costs two `String` allocations plus one `HashMap<char, 
String>` lookup per character. `remap_physical_schema` is called once per 
Parquet file opened (DataFusion `opener/mod.rs:844`, guarded by 
`needs_rewrite`, which is true whenever there is a predicate or the projection 
prunes columns, so in practice always), and it does O(physical x logical) 
comparisons. `spark.sql.caseSensitive` defaults to `false`, so this is the 
default path for every Comet user, Delta or not.
   
   I measured it against a worktree at this PR's base commit, release profile, 
best of 3, using identical already-lowercase ASCII column names, which is the 
friendliest case for the old code. Per Parquet file opened:
   
   | columns | base | this PR | |
   |---|---|---|---|
   | 23 | 7.2 us | 138 us | 19x |
   | 64 | 24.2 us | 912 us | 38x |
   | 200 | 141 us | 8.57 ms | 61x |
   | 1024 | 3.83 ms | 230 ms | 60x |
   
   End to end through a real `DataSourceExec`, including Parquet decode:
   
   | shape | base | this PR | |
   |---|---|---|---|
   | 500 files x 24 cols, project 10 | 70.4 ms | 162 ms | 2.3x |
   | 100 files x 200 cols, project 20 | 28.2 ms | 338 ms | 12.0x |
   
   A 10,000-file scan of a 200-column table would pay roughly 84 seconds of 
extra CPU.
   
   There is an exact fast path available. I dumped the real table your 
generator produces on JDK 17 and checked it: the only ASCII entries are the 26 
plain `A-Z` to `a-z` single-character mappings, and no ASCII codepoint is 
U+03A3. So for two pure-ASCII names, `java_lowercase(a) == java_lowercase(b)` 
is equivalent to `a.eq_ignore_ascii_case(b)`. Would you add that guard at the 
top of `names_equal_ignore_case_java`?
   
   ```rust
   if a.is_ascii() && b.is_ascii() {
       return a.eq_ignore_ascii_case(b);
   }
   ```
   
   That keeps the Kelvin sign and dotted-I cases working, since neither is 
ASCII, while making the overwhelmingly common case free. Hoisting the 
lowercased forms out of the O(P x L) loop in `remap_physical_schema` (lowercase 
each schema once, then compare precomputed strings) would cover the remaining 
non-ASCII case, and `check_column_duplicate` would benefit from the same 
treatment.



-- 
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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to