Repository navigation
Saída polars e retomada trocam valor sem aviso #241
Description
Com entrada polars, a conversão final para polars e a releitura da retomada ainda trocam valores que o modelo devolveu certo, sem aviso nem erro. O #232 e o #238 decidem o tipo da coluna pela anotação só em parte dos casos (coluna sem valor, campo dict e união de data ou hora com outro tipo), e o #237 devolve exato só o inteiro de campo anotado int. Fora disso, o pyarrow tipa a coluna pelo primeiro valor e converte os demais: um inteiro vira duração ou data, um True vira 1.0, um horário sem fuso ganha UTC e um Decimal("NaN") vira nulo. Na retomada, o inteiro acima de 2**53 de campo com outra anotação que admite int volta arredondado. Quem usa polars recebe o valor errado na coluna tipada e só percebe se conferir linha a linha; quem usa pandas não é afetado, porque a saída pandas guarda os objetos Python.
Medido na main em 97a5100, com Python 3.12, polars 2.0.0, pyarrow 26.0.0 e pandas 3.0.6. Os trechos usam esta função, que simula o provider e roda dataframeit numa entrada polars:
import datetime as dt
from decimal import Decimal
from typing import Annotated, NewType, Optional, Union
from unittest.mock import patch
import polars as pl
from pydantic import Field, create_model
from dataframeit import dataframeit
from dataframeit.errors import ProviderAbortError
def rodar(tipo, respostas, df=None, **opcoes):
"""Roda dataframeit com o provider simulado devolvendo `respostas` em ordem."""
modelo = create_model("M", campo=(tipo, ...))
fila = iter(respostas)
def llm(*args, **kwargs):
resposta = next(fila)
if isinstance(resposta, BaseException):
raise resposta
return {"data": {"campo": resposta}, "usage": None}
if df is None:
df = pl.DataFrame({"texto": [f"t{i}" for i in range(len(respostas))]})
with patch("dataframeit.core.call_langchain", side_effect=llm), patch(
"dataframeit.core.validate_provider_dependencies"
):
return dataframeit(df, modelo, "{texto}", track_tokens=False, max_retries=1, **opcoes)Saída
Union[timedelta, int]: o 5 vira cinco microssegundos.
saida = rodar(Union[dt.timedelta, int], [dt.timedelta(days=1), 5])
print(saida.schema["campo"], saida["campo"].to_list())
# Duration(time_unit='us') [datetime.timedelta(days=1), datetime.timedelta(microseconds=5)]Union[float, bool]: o True vira 1.0.
saida = rodar(Union[float, bool], [1.5, True])
print(saida.schema["campo"], saida["campo"].to_list())
# Float64 [1.5, 1.0]Optional[datetime] com um valor com fuso e outro sem: o sem fuso ganha UTC, e as 10h locais passam a 10h UTC.
utc = dt.timezone.utc
saida = rodar(Optional[dt.datetime], [dt.datetime(2026, 1, 2, 10, tzinfo=utc), dt.datetime(2026, 1, 2, 10)])
print(saida.schema["campo"], saida["campo"].to_list())
# Datetime(time_unit='us', time_zone='UTC') [datetime.datetime(2026, 1, 2, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC')),
# datetime.datetime(2026, 1, 2, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))]NewType sobre date unido a int, e o apelido type X = date | int (Python 3.12 ou mais): a regra do #238 não os reconhece, e o 5 vira 1970-01-06, como antes do #238 acontecia com Union[date, int].
DataJudicial = NewType("DataJudicial", dt.date)
type DataOuNumero = dt.date | int
for tipo in (Union[DataJudicial, int], DataOuNumero):
saida = rodar(tipo, [dt.date(2026, 1, 2), 5])
print(saida.schema["campo"], saida["campo"].to_list())
# Date [datetime.date(2026, 1, 2), datetime.date(1970, 1, 6)]
# Date [datetime.date(2026, 1, 2), datetime.date(1970, 1, 6)]Decimal("NaN") num campo que o aceita: vira nulo na saída, e a retomada dessa saída recusa a linha já paga.
tipo = Annotated[Decimal, Field(allow_inf_nan=True)]
saida = rodar(tipo, [Decimal("1.5"), Decimal("NaN")])
print(saida["campo"].to_list())
# [Decimal('1.5'), None]
interrompida = rodar(tipo, [Decimal("1.5"), Decimal("NaN"), ProviderAbortError("limite")])
rodar(tipo, [Decimal("2")], df=interrompida, resume=True)
# ValueError: O DataFrame contém linhas processadas incompatíveis com o modelo atual:
# campos incompatíveis ['campo']. Inclua-os em reprocess_columns=['campo'].Retomada
Uma saída polars interrompida guarda exato o inteiro acima de 2**53 em Union[int, float], em NewType("Id", int) e no apelido type MeuInt = int, mas a retomada dela devolve a coluna inteira em Float64, com o valor arredondado. Com a anotação int, coberta pelo #237, o valor volta exato.
GRANDE = 2**53 + 1
Id = NewType("Id", int)
type MeuInt = int
for tipo in (int, Union[int, float], Id, MeuInt):
interrompida = rodar(tipo, [GRANDE, ProviderAbortError("limite")])
retomada = rodar(tipo, [7], df=interrompida, resume=True)
print(interrompida["campo"].to_list(), "->", retomada.schema["campo"], retomada["campo"].to_list())
# [9007199254740993, None] -> Int64 [9007199254740993, 7]
# [9007199254740993, None] -> Float64 [9007199254740992.0, 7.0]
# [9007199254740993, None] -> Float64 [9007199254740992.0, 7.0]
# [9007199254740993, None] -> Float64 [9007199254740992.0, 7.0]Nota: dicts vazios no polars 1.6 a 1.9
Num campo Any em que o modelo devolve só dicts vazios ({} nas três linhas), a saída polars cai com ShapeError depois das três chamadas, e o custo delas se perde: could not create a new DataFrame: series 3 has length 3 while series "campo" has length 0. Medi com polars 1.5.0 (sai Object), 1.6.0 e 1.9.0 (ShapeError) e 1.10.0 e 2.0.0 (sai Struct({})). A correção ficou fora do #220 e do #236 porque pede um contorno que só essa faixa de versões exercitaria; fica registrada aqui para decidir se a faixa entra no limite inferior do extra polars.
Causa
Em utils.py, from_pandas converte cada coluna do modelo por _to_polars, que chama polars.from_pandas numa coluna de objetos. O pyarrow tipa a coluna pelo primeiro valor não nulo e converte os demais sem erro: o int depois de um timedelta vira microssegundos, o bool depois de um float vira 1.0, o datetime sem fuso depois de um com fuso recebe o fuso do primeiro. O Decimal("NaN") vira nulo porque o pandas o conta como ausente (pd.isna(Decimal("NaN")) é True), e o pyarrow, com from_pandas=True, grava nulo no lugar.
A regra do #238, _mixes_date_or_time, compara cada membro da união com date, datetime e time por identidade. Um NewType não é nenhum deles, e o apelido type não tem Union como origem, de modo que a função devolve False para os dois e a coluna segue para o pyarrow.
Na retomada, to_pandas passa a entrada polars por _python_ints, que só troca a coluna por inteiros Python quando _without_optional(anotação) is int. Nas outras anotações, a coluna Int64 com o nulo da linha pendente passa pelo DataFrame.to_pandas() do polars, que a leva a float64 e arredonda o inteiro acima de 2**53.
Proposta
Decidir o tipo da coluna pela anotação também nesses casos, ou, quando a anotação não basta, sair Object com o aviso que o #220 já emite. Em concreto: resolver NewType (pelo __supertype__) e o apelido type (pelo __value__) antes de aplicar as regras do #232, do #238 e do #237; tratar como mistura que sai Object a união de timedelta com número e de bool com float; e, nos valores, sair Object quando a coluna datetime junta valores com e sem fuso ou quando um Decimal é NaN. Na retomada, aplicar a troca de _python_ints a toda anotação que admita int, e não só à anotação int.
Activity