Skip to content

Saída polars e retomada trocam valor sem aviso #241

Open

Description

@bdcdo

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions