Skip to content

Commit 4ac7b9d

Browse files
committed
Add VGI 2 function signature metadata
1 parent 9d777be commit 4ac7b9d

17 files changed

Lines changed: 363 additions & 6 deletions

‎docs/catalog-interface.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Information about a function in a schema:
127127
| `function_type` | `FunctionType` | `SCALAR` or `TABLE` |
128128
| `arguments` | `SerializedSchema` | Argument schema as Arrow bytes |
129129
| `output_schema` | `SerializedSchema` | Output schema as Arrow bytes |
130+
| `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 |
130131
| `comment` | `str \| None` | Optional description |
131132
| `tags` | `dict[str, str]` | Key-value metadata |
132133

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
**VGI examined:** `588bb2fd99de6a58461b3dfbbf26cc604da4c94e`
88
**vgi-python examined:** `97b68f0f42d560bbc00947eddf39370ca167aa2d`
99

10+
**Function-signature decision:** 2026-09-10 — named fixed parameters,
11+
authoritative typed defaults, and bind-time resolved names are part of VGI 2.0;
12+
there is no protocol overload identifier.
13+
1014
## Executive answer
1115

1216
Nested schemas are **not** the only possible VGI protocol change.
@@ -26,6 +30,43 @@ My confidence is **high that the audit has found the DuckDB 2.0 changes that alt
2630

2731
VGI protocol 2.0 will not carry a v1 compatibility path. A 1.x peer is rejected during protocol negotiation. The DuckDB 1.5 extension can still implement the v2 wire format; its engine adapter simply produces the subset of the v2 AST that DuckDB 1.5 can offer.
2832

33+
## Accepted function-signature contract
34+
35+
DuckDB 2.0 can bind named arguments and defaults for scalar, aggregate, and
36+
window functions. VGI models the portable semantics rather than exposing a
37+
DuckDB overload handle:
38+
39+
1. For scalar and aggregate functions, fields in `FunctionInfo.arguments` are
40+
ordered fixed signature slots and each field name is the SQL/VGI parameter
41+
name. `vgi_arg=named` remains reserved for table-function-style named-only
42+
options. `vgi_const` is orthogonal to naming.
43+
2. `FunctionInfo.parameter_default_values` is a nullable IPC-serialized
44+
`RecordBatch`. When present it has exactly one row and contains only
45+
defaulted parameters, in signature order. Column absence means required/no
46+
default; a present null scalar means an explicit `NULL` default. Each column
47+
type exactly matches its argument field. Scalar and aggregate defaults form
48+
a trailing sequence of fixed parameters, and varargs cannot have defaults.
49+
3. `BindRequest.argument_names` and `AggregateBindRequest.argument_names` are
50+
nullable `list<utf8>` values aligned with the complete logical argument
51+
order before constant arguments are separated for execution. Fixed slots
52+
carry their declared names, unnamed varargs carry null elements, and named
53+
varargs retain caller-provided names. A null list means the engine cannot
54+
provide resolved names.
55+
56+
The legacy `vgi_default` field metadata is discovery-only and may be removed in
57+
a later protocol. It is never authoritative when the typed batch is present.
58+
Implementations select behavior by inspecting the bound arguments, types, and
59+
resolved names; VGI does not add `overload_id`.
60+
61+
The DuckDB 1.5 adapter implements this VGI 2.0 wire shape now. It advertises and
62+
validates typed defaults and synthesizes fixed names plus null unnamed-vararg
63+
entries. Because DuckDB 1.5 cannot register scalar/aggregate named calls or
64+
engine-applied defaults, SQL callers must still provide those values
65+
positionally. A DuckDB 2.0 adapter can map the same metadata to
66+
`FunctionSignature` without changing the worker protocol. DataFusion, Spark,
67+
and SQLite adapters should normalize their native call forms to the same bind
68+
representation.
69+
2970
## 1. Protocol version and rollout
3071

3172
At the start of the audit, the application protocol declared `1.5.0`. It now declares `2.0.0` in the canonical `vgi-python/vgi/protocol_version.txt`. The documented version rules treat a parameter or return-type change as a major change, the framework enforces an exact major/minor match, and the C++ consumer rejects response column-count drift.

‎tests/catalog/test_example_worker_catalog.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,25 @@ def test_function_info_has_arguments(self) -> None:
217217
field_names = [f.name for f in args_schema]
218218
assert "value" in field_names
219219

220+
def test_scalar_signature_names_and_typed_defaults(self) -> None:
221+
"""Fixed field names and the typed default batch share one signature order."""
222+
client = Client(EXAMPLE_WORKER)
223+
attach_result = client.catalog_attach(
224+
name="example", options={}, data_version_spec=None, implementation_version=None
225+
)
226+
functions = _get_all_functions(client, attach_result.attach_opaque_data)
227+
probe = next(fn for fn in functions if fn.name == "argument_names_probe")
228+
229+
arguments = pa.ipc.read_schema(pa.py_buffer(probe.arguments))
230+
assert arguments.names == ["left", "right", "scale"]
231+
assert all(b"vgi_arg" not in (field.metadata or {}) for field in arguments)
232+
233+
defaults = probe.parameter_default_values
234+
assert defaults is not None
235+
assert defaults.schema.names == ["scale"]
236+
assert defaults.column("scale").type == pa.int64()
237+
assert defaults.column("scale")[0].as_py() == 2
238+
220239
def test_function_info_has_description(self) -> None:
221240
"""FunctionInfo has description from docstring or Meta."""
222241
client = Client(EXAMPLE_WORKER)

‎tests/catalog/test_serialization.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
"""Tests for catalog dataclass serialization/deserialization."""
44

5+
from decimal import Decimal
6+
57
import pyarrow as pa
68
import pytest
79
from vgi_rpc.utils import deserialize_record_batch
@@ -776,6 +778,37 @@ def test_scalar_function(self) -> None:
776778
assert restored.arguments == original.arguments
777779
assert restored.output_schema == original.output_schema
778780

781+
def test_function_parameter_default_values_round_trip(self) -> None:
782+
"""FunctionInfo preserves typed defaults and explicit nulls."""
783+
args_schema = schema(value=pa.int64(), scale=pa.decimal128(10, 2), label=pa.string())
784+
output_schema = schema(result=pa.int64())
785+
defaults = pa.RecordBatch.from_arrays(
786+
[
787+
pa.array([Decimal("1.25")], type=pa.decimal128(10, 2)),
788+
pa.array([None], type=pa.string()),
789+
],
790+
names=["scale", "label"],
791+
)
792+
original = FunctionInfo(
793+
name="configured",
794+
schema_path=["main"],
795+
function_type=FunctionType.SCALAR,
796+
arguments=SerializedSchema(args_schema.serialize().to_pybytes()),
797+
output_schema=SerializedSchema(output_schema.serialize().to_pybytes()),
798+
parameter_default_values=defaults,
799+
comment=None,
800+
tags={},
801+
)
802+
803+
restored = FunctionInfo.deserialize_from_bytes(original.serialize_to_bytes())
804+
805+
assert restored.parameter_default_values is not None
806+
actual = restored.parameter_default_values
807+
assert actual.schema.names == ["scale", "label"]
808+
assert actual.column("scale").type == pa.decimal128(10, 2)
809+
assert str(actual.column("scale")[0].as_py()) == "1.25"
810+
assert actual.column("label")[0].as_py() is None
811+
779812
def test_table_function(self) -> None:
780813
"""Test with table function type."""
781814
args_schema = schema(count=pa.int32())

‎tests/conformance/test_scalar_attach_opaque_data.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class Meta:
3535
def on_bind(cls, params: BindParameters) -> BindResult:
3636
_seen["attach_opaque_data"] = params.attach_opaque_data
3737
_seen["transaction_opaque_data"] = params.transaction_opaque_data
38+
_seen["argument_names"] = params.argument_names
3839
return BindResult(pa.int64())
3940

4041
@classmethod
@@ -45,7 +46,11 @@ def compute(
4546
return x
4647

4748

48-
def _bind(attach_opaque_data: bytes | None, transaction_opaque_data: bytes | None) -> None:
49+
def _bind(
50+
attach_opaque_data: bytes | None,
51+
transaction_opaque_data: bytes | None,
52+
argument_names: list[str | None] | None = None,
53+
) -> None:
4954
_seen.clear()
5055
# The worker unwraps the sealed attach to the framework plaintext
5156
# ``uuid(16) || catalog_bytes`` and threads it as ``attach_plaintext``; the
@@ -60,6 +65,7 @@ def _bind(attach_opaque_data: bytes | None, transaction_opaque_data: bytes | Non
6065
input_schema=pa.schema([("x", pa.int64())]),
6166
attach_opaque_data=attach_opaque_data,
6267
transaction_opaque_data=transaction_opaque_data,
68+
argument_names=argument_names,
6369
),
6470
attach_plaintext=attach_plaintext,
6571
)
@@ -84,3 +90,9 @@ def test_on_bind_attach_opaque_data_optional() -> None:
8490
_bind(attach_opaque_data=None, transaction_opaque_data=None)
8591
assert _seen["attach_opaque_data"] is None
8692
assert _seen["transaction_opaque_data"] is None
93+
94+
95+
def test_on_bind_receives_argument_names() -> None:
96+
"""Resolved logical argument names reach scalar implementation bind code."""
97+
_bind(None, None, ["x", None])
98+
assert _seen["argument_names"] == ["x", None]

‎tests/test_argument_spec.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
_format_range,
2727
argument_specs_to_schema,
2828
extract_argument_specs,
29+
parameter_default_values_from_specs,
2930
schema_to_argument_specs,
3031
)
3132
from vgi.arguments import AnyArrow, AnyArrowValue, Arg, TableInput
@@ -824,3 +825,55 @@ def compute(
824825
# The columnar Param carries no constraints.
825826
assert specs["value"].choices_json is None
826827
assert specs["value"].range_notation is None
828+
829+
def test_scalar_signature_defaults_build_typed_batch(self) -> None:
830+
"""Python method defaults become typed FunctionInfo default values."""
831+
from vgi.arguments import ConstParam, Param, Returns
832+
from vgi.scalar_function import ScalarFunction
833+
834+
class DefaultedScalar(ScalarFunction):
835+
class Meta:
836+
name = "defaulted_scalar"
837+
838+
@classmethod
839+
def compute(
840+
cls,
841+
value: Annotated[pa.Int64Array, Param()],
842+
factor: Annotated[int, ConstParam()] = 2,
843+
label: Annotated[str | None, ConstParam(arrow_type=pa.string())] = None,
844+
) -> Annotated[pa.Int64Array, Returns()]:
845+
raise AssertionError("metadata-only test")
846+
847+
specs = extract_argument_specs(DefaultedScalar)
848+
defaults = parameter_default_values_from_specs(specs, require_trailing=True)
849+
850+
assert defaults is not None
851+
assert defaults.num_rows == 1
852+
assert defaults.schema.names == ["factor", "label"]
853+
assert defaults.column("factor").type == pa.int64()
854+
assert defaults.column("factor")[0].as_py() == 2
855+
assert defaults.column("label").type == pa.string()
856+
assert defaults.column("label")[0].as_py() is None
857+
858+
def test_typed_default_requires_concrete_parameter_type(self) -> None:
859+
"""An ANY parameter cannot carry an untyped protocol default."""
860+
spec = ArgumentSpec(
861+
name="value",
862+
position=0,
863+
arrow_type=pa.null(),
864+
is_any_type=True,
865+
default_value=1,
866+
)
867+
868+
with pytest.raises(ValueError, match="typed defaults require an explicit parameter type"):
869+
parameter_default_values_from_specs([spec], require_trailing=True)
870+
871+
def test_typed_defaults_reject_case_insensitive_duplicate_names(self) -> None:
872+
"""Default-batch field names must identify one signature slot."""
873+
specs = [
874+
ArgumentSpec(name="Scale", position=0, arrow_type=pa.int64()),
875+
ArgumentSpec(name="scale", position=1, arrow_type=pa.int64(), default_value=2),
876+
]
877+
878+
with pytest.raises(ValueError, match="Duplicate function parameter name"):
879+
parameter_default_values_from_specs(specs)

‎tests/test_protocol_classes.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,28 @@ def _make_bind(self) -> BindRequest:
11831183
function_type=FunctionType.TABLE,
11841184
)
11851185

1186+
def test_argument_names_round_trip(self) -> None:
1187+
"""Bind requests preserve aligned names, including unnamed varargs."""
1188+
from vgi.invocation import FunctionType
1189+
from vgi.protocol import AggregateBindRequest, BindRequest
1190+
1191+
bind = BindRequest(
1192+
function_name="f",
1193+
arguments=Arguments(),
1194+
function_type=FunctionType.SCALAR,
1195+
argument_names=["value", None, "named_tail"],
1196+
)
1197+
restored = BindRequest.deserialize_from_bytes(bind.serialize_to_bytes())
1198+
assert restored.argument_names == ["value", None, "named_tail"]
1199+
1200+
aggregate_bind = AggregateBindRequest(
1201+
function_name="agg",
1202+
arguments=Arguments(),
1203+
argument_names=["value", "separator"],
1204+
)
1205+
restored_aggregate = AggregateBindRequest.deserialize_from_bytes(aggregate_bind.serialize_to_bytes())
1206+
assert restored_aggregate.argument_names == ["value", "separator"]
1207+
11861208
def test_init_request_bind_call_is_binary(self) -> None:
11871209
"""``InitRequest.bind_call`` serializes as binary and round-trips."""
11881210
from vgi.protocol import InitRequest

‎vgi/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
VGI_VARARGS_TRUE,
2727
ArgumentSpec,
2828
argument_specs_to_schema,
29+
parameter_default_values_from_specs,
2930
schema_to_argument_specs,
3031
)
3132
from vgi.arguments import (
@@ -132,6 +133,7 @@
132133
"AuthContext",
133134
"CallContext",
134135
"argument_specs_to_schema",
136+
"parameter_default_values_from_specs",
135137
"BindRequest",
136138
"ColumnBounds",
137139
"ColumnRefNode",

‎vgi/_test_fixtures/scalar/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from vgi._test_fixtures.scalar.arithmetic import (
2121
AddValuesFunction,
22+
ArgumentNamesProbeFunction,
2223
CachedAddConstScalarFunction,
2324
CachedDoubleScalarFunction,
2425
CachedLabelScalarFunction,
@@ -92,6 +93,7 @@
9293
__all__ = [
9394
"_POINT_STRUCT_TYPE",
9495
"AddValuesFunction",
96+
"ArgumentNamesProbeFunction",
9597
"AnyMixedIntFunction",
9698
"AnyMixedStrFunction",
9799
"BernoulliFunction",

‎vgi/_test_fixtures/scalar/arithmetic.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,34 @@ def compute(
281281
return result
282282

283283

284+
class ArgumentNamesProbeFunction(ScalarFunction):
285+
"""Verify that the engine forwards the complete resolved bind signature."""
286+
287+
class Meta:
288+
"""Function metadata."""
289+
290+
name = "argument_names_probe"
291+
description = "Checks VGI 2.0 bind-time argument names"
292+
293+
@classmethod
294+
def on_bind(cls, params: BindParameters) -> BindResult:
295+
"""Reject a bind that loses names or separates ConstParam too early."""
296+
expected = ["left", "right", "scale"]
297+
if params.argument_names != expected:
298+
raise ValueError(f"argument_names_probe expected {expected!r}, got {params.argument_names!r}")
299+
return BindResult(pa.int64())
300+
301+
@classmethod
302+
def compute(
303+
cls,
304+
left: Annotated[pa.Int64Array, Param(doc="Left value")],
305+
right: Annotated[pa.Int64Array, Param(doc="Right value")],
306+
scale: Annotated[int, ConstParam("Scale factor", arrow_type=pa.int64())] = 2,
307+
) -> Annotated[pa.Int64Array, Returns(pa.int64())]:
308+
"""Add both inputs and multiply by the constant scale."""
309+
return pc.multiply(pc.add(left, right), scale)
310+
311+
284312
class SumValuesFunction(ScalarFunction):
285313
"""Sums multiple numeric values.
286314

0 commit comments

Comments
 (0)