Skip to content

Commit bc74cc7

Browse files
authored
feat: route next_day and levenshtein collated input through the codegen dispatcher (#5720)
1 parent 0b4549f commit bc74cc7

13 files changed

Lines changed: 409 additions & 27 deletions

File tree

docs/source/user-guide/latest/expressions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ The type-name conversion functions (`bigint`, `binary`, `boolean`, `date`, `deci
287287
| `month` || Native | |
288288
| `monthname` ||| Abbreviated month name (Spark 4.0+) |
289289
| `months_between` || Codegen dispatch | |
290-
| `next_day` || Native | |
290+
| `next_day` || Hybrid | Non-UTF8_BINARY collated `dayOfWeek` routes through the JVM codegen dispatcher; other input runs natively |
291291
| `now` ||| Constant-folded to a literal (alias of `current_timestamp`) |
292292
| `quarter` || Native | |
293293
| `second` || Native | |
@@ -576,7 +576,7 @@ The type-name conversion functions (`bigint`, `binary`, `boolean`, `date`, `deci
576576
| `left` || Native | |
577577
| `len` || Native | |
578578
| `length` || Native | |
579-
| `levenshtein` || Native | |
579+
| `levenshtein` || Hybrid | Non-UTF8_BINARY collated input routes through the JVM codegen dispatcher; other input runs natively |
580580
| `locate` || Codegen dispatch | |
581581
| `lower` || Hybrid | |
582582
| `lpad` ||| |

docs/source/user-guide/latest/understanding-comet-plans.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ natively in DataFusion" from "runs Spark's generated code inside a Comet
122122
kernel". The annotation only appears for expressions Comet actually routed
123123
through the dispatcher on this plan — either because no native DataFusion
124124
implementation exists (`CometCodegenDispatch` serdes such as `hypot`,
125-
`levenshtein`, `pmod`), or because a native path exists but declined this
125+
`pmod`, `months_between`), or because a native path exists but declined this
126126
specific input (`CodegenDispatchFallback` mixins). It is not a general "this
127127
expression ran here" marker.
128128

@@ -132,7 +132,7 @@ Example:
132132
spark.conf.set("spark.comet.exec.scalaUDF.codegen.enabled", "true")
133133
spark.conf.set("spark.comet.explain.codegen.enabled", "true")
134134

135-
val df = spark.sql("SELECT hypot(a, b), levenshtein(s1, s2) FROM t")
135+
val df = spark.sql("SELECT hypot(a, b), pmod(a, b) FROM t")
136136
println(new org.apache.comet.ExtendedExplainInfo()
137137
.generateExtendedInfo(df.queryExecution.executedPlan))
138138
```
@@ -141,14 +141,14 @@ Output:
141141

142142
```
143143
CometColumnarToRow
144-
+- CometProject [COMET-INFO: JVM codegen dispatcher: hypot, levenshtein]
144+
+- CometProject [COMET-INFO: JVM codegen dispatcher: hypot, pmod]
145145
+- CometNativeScan parquet spark_catalog.default.t
146146
147147
Comet accelerated 2 out of 2 eligible operators (100%). Final plan contains 1 transitions between Spark and Comet. Accelerated expressions: 0 native, 2 codegen dispatch.
148148
```
149149

150150
Note that the operator is still `CometProject` (Comet-accelerated); only the
151-
per-expression evaluation for `hypot` and `levenshtein` takes the JVM codegen
151+
per-expression evaluation for `hypot` and `pmod` takes the JVM codegen
152152
path.
153153

154154
### `spark.comet.explain.format`

spark/src/main/scala/org/apache/comet/serde/datetime.scala

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,13 +433,16 @@ object CometConvertTimezone
433433
}
434434
}
435435

436-
object CometNextDay extends CometExpressionSerde[NextDay] {
436+
/**
437+
* The native `next_day` kernel reads its `dayOfWeek` argument as raw bytes, so a non-UTF8_BINARY
438+
* collation is reported as `Incompatible` and `CodegenDispatchFallback` routes it through the JVM
439+
* codegen dispatcher instead of failing the projection back to Spark. Dispatching is not only the
440+
* compatible answer here, it is also the faster one: over 1M rows a collated `next_day` measured
441+
* 70ms dispatched against 89ms for Spark. See
442+
* https://github.com/apache/datafusion-comet/issues/5591.
443+
*/
444+
object CometNextDay extends CometExpressionSerde[NextDay] with CodegenDispatchFallback {
437445

438-
/**
439-
* `failOnError` mirrors `spark.sql.ansi.enabled`: under ANSI, Spark throws on a malformed
440-
* `dayOfWeek` rather than returning NULL. The resolved flag is passed to native via the
441-
* `ScalarFunc.fail_on_error` field.
442-
*/
443446
private val collationReason = DatetimeCollation.reason("next_day")
444447

445448
override def getIncompatibleReasons(): Seq[String] =
@@ -452,6 +455,12 @@ object CometNextDay extends CometExpressionSerde[NextDay] {
452455
Compatible()
453456
}
454457
}
458+
459+
/**
460+
* `failOnError` mirrors `spark.sql.ansi.enabled`: under ANSI, Spark throws on a malformed
461+
* `dayOfWeek` rather than returning NULL. The resolved flag is passed to native via the
462+
* `ScalarFunc.fail_on_error` field.
463+
*/
455464
override def convert(expr: NextDay, inputs: Seq[Attribute], binding: Boolean): Option[Expr] = {
456465
val childExpr = expr.children.map(exprToProtoInternal(_, inputs, binding))
457466
val optExpr = scalarFunctionExprToProtoWithReturnType(

spark/src/main/scala/org/apache/comet/serde/strings.scala

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,24 @@ object CometStringTranslate extends CometScalarFunction[StringTranslate]("transl
120120
Some(incompatReason))
121121
}
122122

123-
object CometLevenshtein extends CometExpressionSerde[Levenshtein] {
123+
/**
124+
* The native `levenshtein` kernel compares raw bytes, so a non-UTF8_BINARY collation is reported
125+
* as `Unsupported` and `CodegenDispatchFallback` routes it through the JVM codegen dispatcher
126+
* instead of failing the projection back to Spark. Dispatching is not only the compatible answer
127+
* here, it is also the faster one: over 1M rows a collated `levenshtein` measured 341ms
128+
* dispatched against 378ms for Spark. See https://github.com/apache/datafusion-comet/issues/5591.
129+
*/
130+
object CometLevenshtein extends CometExpressionSerde[Levenshtein] with CodegenDispatchFallback {
131+
132+
private val collationReason =
133+
"Non-default (non-UTF8_BINARY) collated input. The native kernel compares raw bytes, so " +
134+
"collation-aware comparison has no native path."
124135

125-
override def getUnsupportedReasons(): Seq[String] = Seq(
126-
"Non-default collation (non-UTF8_BINARY) is not supported")
136+
override def getUnsupportedReasons(): Seq[String] = Seq(collationReason)
127137

128138
override def getSupportLevel(expr: Levenshtein): SupportLevel =
129139
if (expr.children.exists(child => QueryPlanSerde.isStringCollationType(child.dataType))) {
130-
Unsupported(Some("Levenshtein with non-default collation is not supported"))
140+
Unsupported(Some(collationReason))
131141
} else {
132142
Compatible()
133143
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
-- Licensed to the Apache Software Foundation (ASF) under one
2+
-- or more contributor license agreements. See the NOTICE file
3+
-- distributed with this work for additional information
4+
-- regarding copyright ownership. The ASF licenses this file
5+
-- to you under the Apache License, Version 2.0 (the
6+
-- "License"); you may not use this file except in compliance
7+
-- with the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing,
12+
-- software distributed under the License is distributed on an
13+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
-- KIND, either express or implied. See the License for the
15+
-- specific language governing permissions and limitations
16+
-- under the License.
17+
18+
-- MinSparkVersion: 4.0
19+
-- Config: spark.sql.ansi.enabled=true
20+
21+
-- ANSI mode with a collated dayOfWeek. A collated next_day runs through the JVM codegen
22+
-- dispatcher (see next_day_collation.sql for why), which evaluates Spark's own generated code, so
23+
-- the ANSI error must surface from the Comet pipeline rather than from a Spark fallback.
24+
25+
-- Sentinel: a recognised collated day name must still execute inside the Comet pipeline under
26+
-- ANSI, through the dispatcher rather than natively. Without it the expect_error queries below
27+
-- would pass vacuously if next_day fell back to Spark, which raises the same error.
28+
query
29+
SELECT next_day(date('2024-01-01'), 'Monday' COLLATE UTF8_LCASE)
30+
31+
-- Case-insensitive collation does not make an unrecognised name recognised.
32+
query expect_error(Illegal input for day of week)
33+
SELECT next_day(date('2024-01-01'), 'NOT_A_DAY' COLLATE UTF8_LCASE)
34+
35+
query expect_error(Illegal input for day of week)
36+
SELECT next_day(date('2024-01-01'), 'NOT_A_DAY' COLLATE UNICODE_CI)
37+
38+
-- An RTRIM collation does not trim the day name before matching, so a padded value throws under
39+
-- ANSI rather than resolving to MONDAY.
40+
query expect_error(Illegal input for day of week)
41+
SELECT next_day(date('2024-01-01'), 'MON ' COLLATE UTF8_LCASE_RTRIM)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
-- Licensed to the Apache Software Foundation (ASF) under one
2+
-- or more contributor license agreements. See the NOTICE file
3+
-- distributed with this work for additional information
4+
-- regarding copyright ownership. The ASF licenses this file
5+
-- to you under the Apache License, Version 2.0 (the
6+
-- "License"); you may not use this file except in compliance
7+
-- with the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing,
12+
-- software distributed under the License is distributed on an
13+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
-- KIND, either express or implied. See the License for the
15+
-- specific language governing permissions and limitations
16+
-- under the License.
17+
18+
-- MinSparkVersion: 4.0
19+
20+
-- Spark 4.0+ widens NextDay's dayOfWeek argument to collated strings, but NextDay resolves it
21+
-- through DateTimeUtils.getDayOfWeekFromString, which upper-cases with Locale.ROOT and matches a
22+
-- fixed literal set. That match takes no collation and the result is a DateType, so collation
23+
-- cannot change the answer. The native kernel reads the argument as raw bytes though, so a
24+
-- collated dayOfWeek is reported as Incompatible and routed through the JVM codegen dispatcher,
25+
-- which runs Spark's own doGenCode inside the Comet pipeline. That keeps the projection in Comet
26+
-- and is measurably faster than the native path: 70ms against 89ms for Spark over 1M rows. See
27+
-- https://github.com/apache/datafusion-comet/issues/5591.
28+
29+
-- This harness disables ConstantFolding, so `x COLLATE ...` survives as a `Collate` node rather
30+
-- than folding into a collated literal. Comet has no serde for `Collate`, but that does not matter
31+
-- here: the dispatcher compiles the whole bound subtree with Spark's own doGenCode, `Collate`
32+
-- included. That is one reason dispatching covers every spelling of a collated argument.
33+
34+
statement
35+
CREATE TABLE test_next_day_collated(d date, dow string) USING parquet
36+
37+
statement
38+
INSERT INTO test_next_day_collated VALUES
39+
(date('2023-01-01'), 'Monday'),
40+
(date('2024-02-29'), 'MON'),
41+
(date('1969-12-31'), 'mo'),
42+
(date('2024-06-15'), 'notaday'),
43+
(date('2024-01-01'), NULL),
44+
(NULL, 'Monday')
45+
46+
-- collated column argument: proves a collated column reaches the Comet operator at all
47+
query
48+
SELECT d, next_day(d, dow COLLATE UTF8_LCASE) FROM test_next_day_collated
49+
50+
-- collated literal argument
51+
query
52+
SELECT d, next_day(d, 'Monday' COLLATE UTF8_LCASE) FROM test_next_day_collated
53+
54+
-- Spark upper-cases the day name before matching, so all three spellings must agree under a
55+
-- case-insensitive collation exactly as they do under UTF8_BINARY.
56+
query
57+
SELECT next_day(d, 'monday' COLLATE UTF8_LCASE), next_day(d, 'MoNdAy' COLLATE UTF8_LCASE), next_day(d, 'mO' COLLATE UTF8_LCASE) FROM test_next_day_collated
58+
59+
-- a second ICU collation, to show the guard is not UTF8_LCASE-only
60+
query
61+
SELECT d, next_day(d, dow COLLATE UNICODE_CI) FROM test_next_day_collated
62+
63+
query
64+
SELECT next_day(d, 'Monday' COLLATE UNICODE_CI) FROM test_next_day_collated
65+
66+
-- unrecognised day name returns NULL outside ANSI mode (ANSI is covered by next_day_ansi.sql)
67+
query
68+
SELECT next_day(date('2024-01-01'), 'notaday' COLLATE UTF8_LCASE)
69+
70+
-- Collation does not introduce trimming: getDayOfWeekFromString matches character for character,
71+
-- so a padded value is still NULL. Mirrors the whitespace case in next_day.sql.
72+
query
73+
SELECT next_day(date('2024-01-01'), ' MO ' COLLATE UTF8_LCASE), next_day(date('2024-01-01'), 'MO ' COLLATE UTF8_LCASE)
74+
75+
-- RTRIM collations are the case where trimming looks plausible: NextDay's inputTypes accept them
76+
-- (StringTypeWithCollation(supportsTrimCollation = true)), and CollationFactory right-trims when
77+
-- building a *collation key*. getDayOfWeekFromString never builds one, so a padded day name is
78+
-- still unmatched and the answer is still NULL, exactly as under UTF8_BINARY.
79+
query
80+
SELECT next_day(date('2024-01-01'), 'MON' COLLATE UTF8_BINARY_RTRIM), next_day(date('2024-01-01'), 'MON ' COLLATE UTF8_BINARY_RTRIM)
81+
82+
query
83+
SELECT next_day(date('2024-01-01'), 'mon' COLLATE UTF8_LCASE_RTRIM), next_day(date('2024-01-01'), 'mon ' COLLATE UTF8_LCASE_RTRIM)
84+
85+
-- literal + literal
86+
query
87+
SELECT next_day(date('2023-01-01'), 'Monday' COLLATE UTF8_LCASE), next_day(date('2023-01-01'), 'Sun' COLLATE UNICODE_CI)
88+
89+
-- A NULL-literal dayOfWeek is omitted: NextDay is nullIntolerant, so NullPropagation folds the
90+
-- whole call to a NULL literal before Comet sees it. The NULL rows in the table above cover
91+
-- NULL-in-data for both arguments.

spark/src/test/resources/sql-tests/expressions/string/collation.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ SELECT concat('Hello' COLLATE UNICODE_CI, 'World' COLLATE UNICODE_CI)
5252
query
5353
SELECT reverse('Hello' COLLATE UNICODE_CI)
5454

55+
-- next_day and levenshtein join the same pattern: their native kernels read the argument as raw
56+
-- bytes, so a collated argument is dispatched rather than run natively. Full coverage lives in
57+
-- datetime/next_day_collation.sql and string/levenshtein_collation.sql.
58+
query
59+
SELECT next_day(date('2024-01-01'), 'Monday' COLLATE UTF8_LCASE), next_day(date('2024-01-01'), 'mo' COLLATE UNICODE_CI)
60+
61+
query
62+
SELECT levenshtein('HELLO' COLLATE UTF8_LCASE, 'hello' COLLATE UTF8_LCASE), levenshtein('kitten' COLLATE UNICODE_CI, 'sitting' COLLATE UNICODE_CI)
63+
5564
-- ============================================================================
5665
-- Collated predicate operands route through the JVM codegen dispatcher.
5766
-- The predicate serdes mark collated cases `Unsupported`; `CodegenDispatchFallback` runs
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
-- Licensed to the Apache Software Foundation (ASF) under one
2+
-- or more contributor license agreements. See the NOTICE file
3+
-- distributed with this work for additional information
4+
-- regarding copyright ownership. The ASF licenses this file
5+
-- to you under the Apache License, Version 2.0 (the
6+
-- "License"); you may not use this file except in compliance
7+
-- with the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing,
12+
-- software distributed under the License is distributed on an
13+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
-- KIND, either express or implied. See the License for the
15+
-- specific language governing permissions and limitations
16+
-- under the License.
17+
18+
-- MinSparkVersion: 4.0
19+
20+
-- Spark 4.0+ widens Levenshtein's arguments to collated strings, but Levenshtein computes the
21+
-- distance with UTF8String.levenshteinDistance, which walks UTF-8 code points and takes no
22+
-- collation. The result is an IntegerType, so collation cannot change the answer. The native
23+
-- kernel compares raw bytes though, so a collated argument is reported as Unsupported and routed
24+
-- through the JVM codegen dispatcher, which runs Spark's own doGenCode inside the Comet pipeline.
25+
-- That keeps the projection in Comet and is measurably faster than the native path: 341ms against
26+
-- 378ms for Spark over 1M rows. See https://github.com/apache/datafusion-comet/issues/5591.
27+
28+
-- This harness disables ConstantFolding, so `x COLLATE ...` survives as a `Collate` node rather
29+
-- than folding into a collated literal. Comet has no serde for `Collate`, but that does not matter
30+
-- here: the dispatcher compiles the whole bound subtree with Spark's own doGenCode, `Collate`
31+
-- included. That is one reason dispatching covers every spelling of a collated argument.
32+
33+
statement
34+
CREATE TABLE test_levenshtein_collated(s1 string, s2 string) USING parquet
35+
36+
statement
37+
INSERT INTO test_levenshtein_collated VALUES ('kitten', 'sitting'), ('frog', 'fog'), ('HELLO', 'hello'), ('abc', 'abc'), ('', 'hello'), (NULL, 'test'), ('hello', NULL), (NULL, NULL)
38+
39+
-- both arguments collated, column form
40+
query
41+
SELECT s1, s2, levenshtein(s1 COLLATE UTF8_LCASE, s2 COLLATE UTF8_LCASE) FROM test_levenshtein_collated
42+
43+
-- The load-bearing case. Spark's levenshtein is NOT collation-aware: under UTF8_LCASE the two
44+
-- values still differ in all five characters, so the answer is 5 and not 0. This is why the
45+
-- byte-oriented native kernel already matches Spark.
46+
query
47+
SELECT levenshtein('HELLO' COLLATE UTF8_LCASE, 'hello' COLLATE UTF8_LCASE)
48+
49+
-- only one side collated; the other is implicitly cast to the collated type
50+
query
51+
SELECT levenshtein(s1 COLLATE UTF8_LCASE, s2) FROM test_levenshtein_collated
52+
53+
query
54+
SELECT levenshtein(s1, s2 COLLATE UTF8_LCASE) FROM test_levenshtein_collated
55+
56+
-- three-argument form takes Levenshtein's other codegen branch (genCodeWithThreshold)
57+
query
58+
SELECT levenshtein(s1 COLLATE UTF8_LCASE, s2 COLLATE UTF8_LCASE, 2) FROM test_levenshtein_collated
59+
60+
query
61+
SELECT levenshtein('kitten' COLLATE UTF8_LCASE, 'sitting' COLLATE UTF8_LCASE, 1)
62+
63+
-- a second ICU collation, to show the guard is not UTF8_LCASE-only
64+
query
65+
SELECT levenshtein(s1 COLLATE UNICODE_CI, s2 COLLATE UNICODE_CI) FROM test_levenshtein_collated
66+
67+
query
68+
SELECT levenshtein('HELLO' COLLATE UNICODE_CI, 'hello' COLLATE UNICODE_CI)
69+
70+
-- non-ASCII input under a collation, mirroring the unicode case in levenshtein.sql
71+
query
72+
SELECT levenshtein('café' COLLATE UTF8_LCASE, 'cafe' COLLATE UTF8_LCASE), levenshtein('你好' COLLATE UTF8_LCASE, '你坏' COLLATE UTF8_LCASE)
73+
74+
-- RTRIM collations are the case where trimming looks plausible: Levenshtein's inputTypes accept
75+
-- them (StringTypeWithCollation(supportsTrimCollation = true)), and CollationFactory right-trims
76+
-- when building a *collation key*. UTF8String.levenshteinDistance never builds one, so the
77+
-- trailing space still counts as an edit and the distance is 1, not 0.
78+
query
79+
SELECT levenshtein('abc ' COLLATE UTF8_BINARY_RTRIM, 'abc' COLLATE UTF8_BINARY_RTRIM)
80+
81+
query
82+
SELECT levenshtein('ABC ' COLLATE UTF8_LCASE_RTRIM, 'abc' COLLATE UTF8_LCASE_RTRIM)
83+
84+
-- A NULL-literal argument is omitted: Levenshtein is nullIntolerant, so NullPropagation folds the
85+
-- whole call to a NULL literal before Comet sees it. The NULL rows in the table above cover
86+
-- NULL-in-data for both arguments.

spark/src/test/scala/org/apache/comet/CometStringExpressionSuite.scala

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import org.apache.spark.sql.{CometTestBase, DataFrame}
2626
import org.apache.spark.sql.internal.SQLConf
2727
import org.apache.spark.sql.types.{DataTypes, StructField, StructType}
2828

29+
import org.apache.comet.CometSparkSessionExtensions.isSpark40Plus
2930
import org.apache.comet.testing.{DataGenOptions, FuzzDataGenerator}
3031
import org.apache.comet.udf.codegen.CometScalaUDFCodegen
3132

@@ -769,4 +770,25 @@ class CometStringExpressionSuite extends CometTestBase with CometCodegenAssertio
769770
}
770771
}
771772

773+
test("levenshtein routes collated strings through the codegen dispatcher (issue #5591)") {
774+
assume(isSpark40Plus, "COLLATE requires Spark 4.0")
775+
// The native levenshtein kernel compares raw bytes, so CometLevenshtein reports a collated
776+
// argument as Unsupported and CodegenDispatchFallback runs Spark's own doGenCode inside the
777+
// Comet pipeline. checkSparkAnswerAndOperator alone would also pass if the projection fell
778+
// back to Spark on a shape this test did not intend, so assertCodegenRan pins that the
779+
// dispatcher is what kept it native. Answer coverage, including the three-argument form and
780+
// RTRIM collations, lives in sql-tests/expressions/string/levenshtein_collation.sql.
781+
val data = Seq(("kitten", "sitting"), ("HELLO", "hello"), ("frog", "fog"), (null, "test"))
782+
withParquetTable(data, "tbl") {
783+
assertCodegenRan {
784+
checkSparkAnswerAndOperator(
785+
"SELECT levenshtein(_1 COLLATE utf8_lcase, _2 COLLATE utf8_lcase) FROM tbl")
786+
checkSparkAnswerAndOperator(
787+
"SELECT levenshtein(_1 COLLATE unicode_ci, _2 COLLATE unicode_ci) FROM tbl")
788+
checkSparkAnswerAndOperator(
789+
"SELECT levenshtein(_1 COLLATE utf8_lcase, _2 COLLATE utf8_lcase, 2) FROM tbl")
790+
}
791+
}
792+
}
793+
772794
}

0 commit comments

Comments
 (0)