Skip to content

Commit 866511c

Browse files
committed
Add argument monotonicity protocol metadata
1 parent 89cf3df commit 866511c

10 files changed

Lines changed: 254 additions & 9 deletions

File tree

‎docs/catalog-interface.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ Information about a function in a schema:
128128
| `arguments` | `SerializedSchema` | Argument schema as Arrow bytes |
129129
| `output_schema` | `SerializedSchema` | Output schema as Arrow bytes |
130130
| `parameter_default_values` | `pa.RecordBatch \| None` | One-row typed defaults batch containing only defaulted parameters in signature order; a present null is an explicit `NULL` default |
131+
| `argument_monotonicity` | `list[ArgumentMonotonicity] \| None` | Scalar-only claims aligned exactly with `arguments`; a vararg declaration occupies one slot |
131132
| `comment` | `str \| None` | Optional description |
132133
| `tags` | `dict[str, str]` | Key-value metadata |
133134

‎docs/metadata.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ info = SumValuesFunction.describe()
8282
| `max_workers` | `int\|None` | `None` (unlimited) | Max parallel workers |
8383
| `stability` | `FunctionStability` | `CONSISTENT` | Output determinism |
8484
| `null_handling` | `NullHandling` | `DEFAULT` | NULL input behavior |
85+
| `argument_monotonicity` | `list[ArgumentMonotonicity]\|None` | `None` | Scalar-only monotonicity claims in argument declaration order |
8586
| `required_settings` | `list[str]` | `[]` | Required DuckDB settings |
8687
| `projection_pushdown` | `bool` | `True` | Enable column pruning |
8788
| `filter_pushdown` | `bool` | `False` | Enable filter pushdown |
@@ -90,6 +91,14 @@ info = SumValuesFunction.describe()
9091
| `distinct_dependent` | `DistinctDependence` | `NOT_DISTINCT_DEPENDENT` | Aggregate DISTINCT sensitivity |
9192
| `output_type` | `pa.DataType\|AnyArrow` | Required for ScalarFunction | Scalar output type |
9293

94+
`argument_monotonicity` is only valid on scalar functions. When present, it
95+
contains exactly one entry for every field in the function's argument schema.
96+
Fixed, defaulted, and constant parameters each occupy one entry, and a vararg
97+
declaration also occupies one entry regardless of how many values a call
98+
supplies. The ordering is the declaration order, so named SQL calls do not
99+
reorder the metadata. Use `None` to make no claims; use
100+
`ArgumentMonotonicity.UNKNOWN` for an explicitly unknown individual slot.
101+
93102
## Metadata Inheritance
94103

95104
Meta attributes are inherited from parent classes:

‎docs/protocol/vgi-protocol-proposed-changes.md‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Proposed VGI protocol changes for DuckDB 2.0
22

3-
**Status:** design/audit report, not an implementation specification
3+
**Status:** design/audit report; the argument-monotonicity subsection records an accepted VGI 2.0 protocol addition
44
**Audit date:** 2026-09-09
55
**Filter-v2 decision:** 2026-09-09 — mandatory in VGI 2.0; no v1 compatibility path
66
**DuckDB range examined:** `v1.5.5..03a1639e3bba0e50bab4c341ebc89137c86b793a` (`v2.0-cyanoptera` plus the multi-column table-function pushdown branch)
@@ -409,15 +409,30 @@ DuckDB replaces the old dynamic-to-string hook with `get_metrics` and `OperatorM
409409

410410
### Function metadata
411411

412-
DuckDB 2.0 adds optimizer-facing per-argument monotonicity and strengthens fallibility handling. VGI already transports scalar stability and null handling, but not monotonicity or “can throw at runtime.” Defaults are conservative in the optimizer, so these are optional metadata additions:
412+
DuckDB 2.0 adds optimizer-facing per-argument monotonicity and strengthens
413+
fallibility handling. VGI 2.0 adds the following nullable field immediately
414+
after `FunctionInfo.null_handling`:
413415

414416
```text
415-
error_mode: unknown | cannot_throw | can_throw_runtime_error
416-
argument_monotonicity: list[unknown | constant | non_decreasing | strictly_increasing |
417-
non_increasing | strictly_decreasing]
417+
argument_monotonicity: list<utf8>?
418418
```
419419

420-
Do not claim `cannot_throw` for remote functions unless transport, worker code, and data-dependent errors are all covered by that guarantee.
420+
The allowed strings are `UNKNOWN`, `CONSTANT`, `NON_DECREASING`,
421+
`STRICTLY_INCREASING`, `NON_INCREASING`, and `STRICTLY_DECREASING`. This field
422+
is valid only for scalar functions. Null means that the function makes no
423+
claims. A present list contains exactly one non-null entry for every ordered
424+
field in `FunctionInfo.arguments`. Fixed, defaulted, and constant parameters
425+
each occupy a slot; a vararg declaration occupies one slot and its property
426+
applies independently to every expansion. The order is declaration order and
427+
is unaffected by `BindRequest.argument_names` or named call syntax.
428+
429+
The DuckDB 1.5 extension validates and preserves this metadata using VGI-owned
430+
types, but does not apply it to the optimizer. A DuckDB 2.0 adapter can later
431+
map the same values to `ArgProperties` without changing the wire format.
432+
433+
`error_mode` remains out of scope. In particular, VGI must not claim that a
434+
remote function cannot throw unless transport, worker code, and all
435+
data-dependent failures are covered by that guarantee.
421436

422437
## 6. Catalog and DML features to reject explicitly unless separately designed
423438

‎tests/catalog/test_catalog_interface.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ViewInfo,
2929
)
3030
from vgi.catalog.catalog_interface import (
31+
ArgumentMonotonicity,
3132
DistinctDependence,
3233
FunctionStability,
3334
MacroType,
@@ -575,6 +576,7 @@ def test_default_values(self) -> None:
575576
# Scalar behavior fields default to None (set by _function_to_info)
576577
assert info.stability is None
577578
assert info.null_handling is None
579+
assert info.argument_monotonicity is None
578580

579581
# Documentation fields
580582
assert info.examples == []
@@ -606,6 +608,7 @@ def test_serialization_roundtrip_with_all_fields(self) -> None:
606608
tags={},
607609
stability=FunctionStability.VOLATILE,
608610
null_handling=NullHandling.SPECIAL,
611+
argument_monotonicity=[],
609612
examples=[
610613
CatalogExample(sql="SELECT test_func(1)"),
611614
CatalogExample(sql="SELECT test_func(2)"),
@@ -634,6 +637,7 @@ def test_serialization_roundtrip_with_all_fields(self) -> None:
634637
# New fields
635638
assert restored.stability == info.stability
636639
assert restored.null_handling == info.null_handling
640+
assert restored.argument_monotonicity == info.argument_monotonicity
637641
# Examples are deserialized to CatalogExample objects
638642
assert len(restored.examples) == len(info.examples)
639643
for restored_ex, orig_ex in zip(restored.examples, info.examples, strict=True):
@@ -660,6 +664,7 @@ def test_enum_serialization(self) -> None:
660664
tags={},
661665
stability=FunctionStability.CONSISTENT_WITHIN_QUERY,
662666
null_handling=NullHandling.SPECIAL,
667+
argument_monotonicity=[],
663668
order_preservation=OrderPreservation.NO_ORDER_GUARANTEE,
664669
order_dependent=OrderDependence.ORDER_DEPENDENT,
665670
distinct_dependent=DistinctDependence.DISTINCT_DEPENDENT,
@@ -672,6 +677,7 @@ def test_enum_serialization(self) -> None:
672677
row = batch.to_pydict()
673678
assert row["stability"][0] == "CONSISTENT_WITHIN_QUERY"
674679
assert row["null_handling"][0] == "SPECIAL"
680+
assert row["argument_monotonicity"][0] == []
675681
assert row["order_preservation"][0] == "NO_ORDER_GUARANTEE"
676682
assert row["order_dependent"][0] == "ORDER_DEPENDENT"
677683
assert row["distinct_dependent"][0] == "DISTINCT_DEPENDENT"
@@ -680,10 +686,56 @@ def test_enum_serialization(self) -> None:
680686
restored = FunctionInfo.deserialize_from_batch(batch)
681687
assert restored.stability == FunctionStability.CONSISTENT_WITHIN_QUERY
682688
assert restored.null_handling == NullHandling.SPECIAL
689+
assert restored.argument_monotonicity == []
683690
assert restored.order_preservation == OrderPreservation.NO_ORDER_GUARANTEE
684691
assert restored.order_dependent == OrderDependence.ORDER_DEPENDENT
685692
assert restored.distinct_dependent == DistinctDependence.DISTINCT_DEPENDENT
686693

694+
def test_argument_monotonicity_round_trip_uses_argument_order(self) -> None:
695+
"""Claims use the serialized declaration order, including named slots."""
696+
arguments = SerializedSchema(
697+
pa.schema([pa.field("value", pa.int64()), pa.field("offset", pa.int64())]).serialize().to_pybytes()
698+
)
699+
info = FunctionInfo(
700+
name="shift",
701+
schema_path=["main"],
702+
function_type=FunctionType.SCALAR,
703+
arguments=arguments,
704+
output_schema=empty_schema_bytes(),
705+
comment=None,
706+
tags={},
707+
argument_monotonicity=[
708+
ArgumentMonotonicity.STRICTLY_INCREASING,
709+
ArgumentMonotonicity.NON_DECREASING,
710+
],
711+
)
712+
713+
batch, _ = deserialize_record_batch(info.serialize_to_bytes())
714+
assert batch.to_pydict()["argument_monotonicity"][0] == ["STRICTLY_INCREASING", "NON_DECREASING"]
715+
assert FunctionInfo.deserialize_from_batch(batch).argument_monotonicity == info.argument_monotonicity
716+
717+
def test_argument_monotonicity_rejects_invalid_shape(self) -> None:
718+
"""The DTO rejects non-scalar and misaligned claims."""
719+
arguments = SerializedSchema(pa.schema([pa.field("value", pa.int64())]).serialize().to_pybytes())
720+
common = {
721+
"name": "bad",
722+
"schema_path": ["main"],
723+
"arguments": arguments,
724+
"output_schema": empty_schema_bytes(),
725+
"comment": None,
726+
"tags": {},
727+
"argument_monotonicity": [ArgumentMonotonicity.UNKNOWN],
728+
}
729+
730+
with pytest.raises(ValueError, match="only valid for scalar functions"):
731+
FunctionInfo(function_type=FunctionType.TABLE, **common)
732+
733+
with pytest.raises(ValueError, match="1 declaration slots"):
734+
FunctionInfo(
735+
function_type=FunctionType.SCALAR,
736+
**{**common, "argument_monotonicity": []},
737+
)
738+
687739
def test_v2_record_without_optional_fields(self) -> None:
688740
"""Deserialize a protocol-v2 record that omits optional fields."""
689741
# Create a minimal protocol-v2 schema without optional fields.

‎tests/test_metadata.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
import pyarrow as pa
10+
import pytest
1011
from vgi_rpc.rpc import OutputCollector
1112

1213
from vgi import (
@@ -19,6 +20,7 @@
1920
functions_to_arrow,
2021
)
2122
from vgi.metadata import (
23+
ArgumentMonotonicity,
2224
arrow_to_functions,
2325
extract_parameters,
2426
resolve_metadata,
@@ -400,6 +402,90 @@ def compute(
400402
assert resolve_metadata(ScalarWithFinalize).has_finalize is False
401403

402404

405+
class TestArgumentMonotonicity:
406+
"""Tests for scalar-only per-declaration monotonicity metadata."""
407+
408+
def test_scalar_metadata_round_trip(self) -> None:
409+
"""A unary scalar claim survives metadata resolution and Arrow IPC."""
410+
from typing import Annotated
411+
412+
from vgi.arguments import Param, Returns
413+
from vgi.scalar_function import ScalarFunction
414+
415+
class IdentityFunction(ScalarFunction):
416+
class Meta:
417+
argument_monotonicity = [ArgumentMonotonicity.STRICTLY_INCREASING]
418+
419+
@classmethod
420+
def compute(
421+
cls,
422+
value: Annotated[pa.Int64Array, Param()],
423+
) -> Annotated[pa.Int64Array, Returns()]:
424+
return value
425+
426+
metadata = resolve_metadata(IdentityFunction)
427+
assert metadata.argument_monotonicity == [ArgumentMonotonicity.STRICTLY_INCREASING]
428+
429+
restored = arrow_to_functions(functions_to_arrow([IdentityFunction]))[0]
430+
assert restored.argument_monotonicity == [ArgumentMonotonicity.STRICTLY_INCREASING]
431+
432+
def test_entry_count_must_match_declaration_slots(self) -> None:
433+
"""A list is aligned with declarations rather than a particular call."""
434+
from typing import Annotated
435+
436+
from vgi.arguments import Param, Returns
437+
from vgi.scalar_function import ScalarFunction
438+
439+
class BadCountFunction(ScalarFunction):
440+
class Meta:
441+
argument_monotonicity: list[ArgumentMonotonicity] = []
442+
443+
@classmethod
444+
def compute(
445+
cls,
446+
value: Annotated[pa.Int64Array, Param()],
447+
) -> Annotated[pa.Int64Array, Returns()]:
448+
return value
449+
450+
with pytest.raises(ValueError, match="1 argument slots"):
451+
resolve_metadata(BadCountFunction)
452+
453+
def test_varargs_declaration_occupies_one_slot(self) -> None:
454+
"""Call-time vararg expansion does not expand metadata declarations."""
455+
from typing import Annotated
456+
457+
from vgi.arguments import Param, Returns
458+
from vgi.scalar_function import ScalarFunction
459+
460+
class VarargsFunction(ScalarFunction):
461+
class Meta:
462+
argument_monotonicity = [ArgumentMonotonicity.NON_DECREASING]
463+
464+
@classmethod
465+
def compute(
466+
cls,
467+
values: Annotated[list[pa.Array[Any]], Param(varargs=True)],
468+
) -> Annotated[pa.Array[Any], Returns()]:
469+
return values[0]
470+
471+
metadata = resolve_metadata(VarargsFunction)
472+
assert len(metadata.parameters) == 1
473+
assert metadata.parameters[0].is_varargs is True
474+
assert metadata.argument_monotonicity == [ArgumentMonotonicity.NON_DECREASING]
475+
476+
def test_rejected_for_non_scalar_function(self) -> None:
477+
"""DuckDB exposes argument properties only on scalar functions."""
478+
479+
class BadTableFunction(TableInOutFunction): # type: ignore[type-arg]
480+
class Meta:
481+
argument_monotonicity = [ArgumentMonotonicity.UNKNOWN]
482+
483+
data: TableInput = Arg[TableInput](0, doc="Input table") # type: ignore[assignment]
484+
485+
with pytest.raises(TypeError, match="only valid for scalar functions"):
486+
resolve_metadata(BadTableFunction)
487+
488+
403489
class TestArrowSerialization:
404490
"""Tests for Arrow serialization of metadata."""
405491

‎vgi/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
value_payload,
8181
)
8282
from vgi.metadata import (
83+
ArgumentMonotonicity,
8384
CatalogFunctionType,
8485
EvaluationContextCapability,
8586
FilterFunctionCapability,
@@ -138,6 +139,7 @@
138139
"ArgumentSpec",
139140
"ArgumentValidationError",
140141
"Arguments",
142+
"ArgumentMonotonicity",
141143
"Auth",
142144
"AuthContext",
143145
"CallContext",

‎vgi/_test_fixtures/scalar/null_handling.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pyarrow.compute as pc
1111

1212
from vgi.arguments import ConstParam, Param, Returns
13-
from vgi.metadata import FunctionExample, NullHandling
13+
from vgi.metadata import ArgumentMonotonicity, FunctionExample, NullHandling
1414
from vgi.scalar_function import ScalarFunction
1515

1616

@@ -87,6 +87,7 @@ class Meta:
8787
name = "null_handling"
8888
description = "Returns value or -5000 if null"
8989
null_handling = NullHandling.SPECIAL
90+
argument_monotonicity = [ArgumentMonotonicity.STRICTLY_INCREASING]
9091
examples = [
9192
FunctionExample(
9293
sql="SELECT null_handling(value) FROM data",

‎vgi/catalog/catalog_interface.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from vgi.arguments import Arguments, SecretLookupEntry
4141
from vgi.exceptions import CatalogReadOnlyError
4242
from vgi.metadata import (
43+
ArgumentMonotonicity,
4344
DistinctDependence,
4445
EvaluationContextCapability,
4546
FilterFunctionCapability,
@@ -696,6 +697,10 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
696697
functions).
697698
null_handling: Scalar function behavior field (None for non-scalar
698699
functions).
700+
argument_monotonicity: Scalar-only properties aligned with the ordered
701+
fields in ``arguments``. A present list has exactly one entry per
702+
declaration slot, including one for a vararg declaration. ``None``
703+
means no claims.
699704
description: Intrinsic documentation from function metadata
700705
(``Meta.description``). ``comment`` (inherited from the base
701706
object) is a separate, shorter operator-facing note from
@@ -801,6 +806,7 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
801806

802807
stability: FunctionStability | None = None
803808
null_handling: NullHandling | None = None
809+
argument_monotonicity: Annotated[list[ArgumentMonotonicity] | None, ArrowType(pa.list_(pa.string()))] = None
804810

805811
description: str = ""
806812
examples: list[CatalogExample] = field(default_factory=list)
@@ -857,6 +863,22 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
857863

858864
required_secrets: list[SecretLookupEntry] = field(default_factory=list)
859865

866+
def __post_init__(self) -> None:
867+
"""Validate scalar-only argument monotonicity claims."""
868+
if self.argument_monotonicity is None:
869+
return
870+
if self.function_type is not FunctionType.SCALAR:
871+
raise ValueError("argument_monotonicity is only valid for scalar functions")
872+
argument_schema = pa.ipc.read_schema(pa.py_buffer(self.arguments))
873+
if len(self.argument_monotonicity) != len(argument_schema):
874+
raise ValueError(
875+
f"argument_monotonicity has {len(self.argument_monotonicity)} entries "
876+
f"but arguments has {len(argument_schema)} declaration slots"
877+
)
878+
for index, value in enumerate(self.argument_monotonicity):
879+
if not isinstance(value, ArgumentMonotonicity):
880+
raise ValueError(f"argument_monotonicity[{index}] is not a valid ArgumentMonotonicity")
881+
860882

861883
@dataclass(frozen=True)
862884
class CopyFromFormatInfo(CatalogObject, ArrowSerializableDataclass):
@@ -3465,6 +3487,7 @@ def _function_to_info(self, func_cls: type, schema_path: SchemaPath) -> Function
34653487
# Scalar/aggregate function behavior fields
34663488
stability=meta.stability if is_scalar else None,
34673489
null_handling=meta.null_handling if (is_scalar or is_aggregate) else None,
3490+
argument_monotonicity=meta.argument_monotonicity if is_scalar else None,
34683491
# Documentation fields
34693492
description=meta.description or "", # Intrinsic from Meta.description
34703493
examples=[

‎vgi/codegen/ts_client.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,6 @@ def emit(out: TextIO) -> None:
419419
body.write(
420420
f"export const decode{ts_name} = (b: Uint8Array): {ts_name} => decodeASD<{ts_name}>({schema_ref}, b);\n",
421421
)
422-
body.write("\n")
423-
424422
out.write("// ============================================================================\n")
425423
out.write(
426424
provenance_comment(

0 commit comments

Comments
 (0)