Skip to content

Assinatura do checkpoint é a mesma em todo processo com condição callable ou conjunto - #267

Merged
bdcdo merged 18 commits into
mainfrom
fix/assinatura-estavel-do-checkpoint
Oct 29, 2026
Merged

bdcdo merged 18 commits into
mainfrom
fix/assinatura-estavel-do-checkpoint

Conversation

@bdcdo

@bdcdo bdcdo commented Oct 22, 2026

Copy link
Copy Markdown
Owner

O quê

Com checkpoint_path, um modelo com condição callable (field_condition) ou com conjunto numa condition ({"field": "tipo", "in": {"pf", "mei", "autonomo", "rural"}}) tinha uma assinatura de checkpoint diferente em cada processo. A execução interrompida por cota depois de 3 de 4 linhas, rodada de novo em outro processo com o mesmo checkpoint_path, avisava que o checkpoint "foi gravado com outra configuração", fazia as 4 chamadas de novo e sobrescrevia o arquivo. Com a condição callable, isso acontecia mesmo com PYTHONHASHSEED fixo.

As duas causas estão em _run_signature. A função da condição faz model_json_schema() falhar, e o fallback repr(questions.model_fields) leva o endereço dela (<function Pedido.<lambda> at 0x7f...>); o _stable_repr não o tira, porque o json.dumps só chama o default para objeto não serializável, e o repr já chega como texto. O conjunto passa pelo to_jsonable_python do Pydantic e vira lista na ordem de iteração, que para texto muda com a semente de hash: cinco sementes deram cinco assinaturas.

Como

  • Quando o json_schema_extra de algum campo, inclusive aninhado, tem uma função ou um conjunto com valor que não é inteiro, o schema da assinatura sai de uma forma canônica dos campos (_schema_form), sem repr. Cada campo entra pelo caminho, com os atributos que o JSON Schema levaria ao LLM (anotação, default, alias, título, descrição, exemplos, restrições, json_schema_extra etc.). Na anotação, o modelo aninhado entra pelo nome, pela docstring e pelo model_config, e o Enum, pelos membros. O conjunto entra em ordem, pelo _stable, que ganhou um parâmetro para converter as folhas.
  • A função entra pelo nome qualificado e pelo código: bytecode, constantes (com recursão no código aninhado, como o de um gerador) e nomes que ela lê, sem arquivo nem linha. A constante frozenset que o compilador gera para d.get("tipo") in {"vip", "ouro"} também é ordenada; sem isso, a própria lambda variava com a semente.
  • Os demais modelos seguem assinados como na main: pelo JSON Schema ou, quando ele falha, pelo repr dos campos.

Itens da issue

  • field_condition: corrigido. A assinatura é a mesma com PYTHONHASHSEED 0, 1 e 2.
  • set em condition: corrigido, também com o conjunto num campo aninhado.
  • Efeito ponta a ponta: corrigido. A execução interrompida depois de 3 linhas num processo é retomada em outro, com outra semente, com 1 chamada (a da 4ª linha) e sem o aviso de outra configuração.

Decisões

  • Compatibilidade. A forma canônica só vale para o modelo com função ou com conjunto que não é só de inteiros no json_schema_extra de um campo. Nos outros, a assinatura não muda, e o checkpoint gravado antes da atualização continua sendo retomado. O teste fixa as assinaturas medidas na main (pydantic 2.14.1 e 2.12.0, sementes 0 e 7) para cinco modelos: um campo só; Literal, Enum, modelo aninhado e condition em dict com lista; conjunto só de inteiros, cuja ordem de iteração não depende da semente, porque o hash do inteiro é ele mesmo; json_schema_extra que é uma função, que o Pydantic chama e cujo resultado é JSON; e modelo sem JSON Schema. A assinatura muda para o modelo com função, que não era estável entre processos, e para o com conjunto de texto, que só era estável com PYTHONHASHSEED fixo. Neste último caso, o checkpoint gravado antes da atualização deixa de ser retomado uma vez, o que o CHANGELOG registra.
  • Duas funções com o mesmo nome qualificado. Só o nome deixaria passar a lambda editada entre uma execução e outra: lambda d: d.get("tipo") == "vip" trocada por == "ouro" tem o mesmo __qualname__, e a retomada juntaria linhas decididas pelas duas condições, que é o que a assinatura existe para impedir. Por isso o código entra junto com o nome. O custo é que o bytecode muda de uma versão menor do Python para outra (a mesma lambda dá bytecodes diferentes na 3.11, na 3.12 e na 3.13), e o checkpoint de um modelo com função não é retomado depois dessa troca; a execução avisa e recomeça, que é o lado seguro. Duas lambdas do mesmo modelo têm o mesmo nome qualificado (Pedido.<lambda>); com o código, elas se distinguem, e duas iguais em campos diferentes são separadas pelo caminho do campo.
  • O que a escolha deixa passar. O que a função lê de fora do próprio código não entra: o valor de uma variável global ou do escopo de fora, o valor padrão de argumento e o corpo de outra função que ela chama. Mudar um deles não muda a assinatura, e a retomada aceita as linhas já concluídas, em que o campo foi pedido ou pulado pela condição anterior. A documentação da retomada, em português e em inglês, diz isso e manda passar resume=False ou outro checkpoint_path para mudar a condição por esses caminhos. Um callable sem código Python, como functools.partial ou um objeto com __call__, entra pelo repr sem o endereço.

Fora deste PR

O json_schema_extra do modelo (model_config = ConfigDict(json_schema_extra={...})) com conjunto de texto ainda assina na ordem de hash: o Pydantic deixa o conjunto cru no JSON Schema, e o default do json.dumps o serializa pelo repr. A biblioteca não lê chave nenhuma do json_schema_extra do modelo, e a issue trata do campo; o caso fica para uma issue própria.

Testes

  • tests/test_assinatura_estavel.py, novo: a assinatura em três subprocessos com sementes diferentes, para a condição callable, o conjunto na condição e o conjunto num campo aninhado; a execução interrompida num processo e retomada em outro; as cinco assinaturas medidas na main; e, na forma canônica, a mesma função recompilada em outra linha assinando igual e cada mudança que altera a resposta mudando a assinatura (corpo, operador e método chamado na lambda, nome da função, código de um gerador dentro dela, depends_on, conjunto, membros do Enum, argumento da anotação, docstring, model_config e campo do modelo aninhado, e cada atributo do campo).
  • Na main, os três testes que fixam a issue reprovam; os de compatibilidade passam por construção, e os de mudança passam porque lá toda assinatura difere de todas.

Closes #191

bdcdo added 16 commits October 21, 2026 22:22
… ou conjunto

Com função ou conjunto no json_schema_extra de um campo, a assinatura sai
de uma forma canônica dos campos: a função entra pelo nome qualificado e
pelo bytecode, constantes e nomes que ela lê, e o conjunto entra ordenado.
Os outros modelos seguem assinados pelo JSON Schema, com a mesma
assinatura de antes.

Refs #191
A página de checkpoints diz o que da função entra na assinatura e o que
fica de fora, e o que fazer para mudar a condição por esses caminhos.

Refs #191
A anotação `schema: object` em _run_signature não muda nada para o ty,
que aceita os três valores atribuídos sem ela, como aceitava os dois
de antes.

Refs #191
… fixam

A condição em functools.partial ou em objeto com __call__ entrava pelo repr
sem o endereço. Editar a função embrulhada, o __call__ ou o estado do objeto
não mudava a assinatura, e a retomada aceitava as linhas concluídas com a
condição anterior, sem aviso. O conjunto nos argumentos do partial e no repr
do objeto ainda variava com a semente de hash.

O partial entra pela função, pelos argumentos e pelas keywords; o objeto, pelo
__call__ da classe, como a função, e pelo __dict__ da instância, ambos na forma
canônica. O chamável sem __call__ em Python, como o embutido, fica pelo repr.
O teste das variações do modelo com condição callable não mudava com a
docstring nem com o model_config do modelo raiz, nem com o nome do modelo
aninhado ou do Enum. Tirar essas partes da forma canônica passava na suíte,
embora as quatro entrem no JSON Schema que o LLM recebe.
…alquer semente

O texto dizia que só o checkpoint gravado com PYTHONHASHSEED fixo deixava de ser
retomado. O conjunto de float, de tupla, {None} e de classe no extra já assinava
igual entre processos sem semente fixa e também muda de assinatura.
Ignorar a lista em _outside_json mandava o modelo de volta ao JSON Schema, e a
assinatura voltava a variar com a semente de hash, sem que a suíte reprovasse.
A descida no estado do objeto chamável não guardava o que já tinha aberto:
o atributo que aponta para o próprio objeto, ou o ciclo entre dois objetos,
levava _run_signature a RecursionError, e com checkpoint_path o dataframeit()
falhava antes da primeira linha.

A descida leva agora o id dos objetos no caminho, e o objeto reencontrado
entra por um marcador com o nome da classe, sem endereço. O mesmo objeto em
dois atributos, sem ciclo, entra pelos dois. O teste roda o objeto
auto-referente e o ciclo entre dois objetos em três sementes.
O estado do objeto chamável era só o __dict__. O objeto com __slots__ entrava
com estado vazio, e trocar Regra("vip") por Regra("comum") dava a mesma
assinatura: a retomada aceitava as linhas feitas com a condição anterior,
sem aviso.

O estado junta o __dict__ aos slots atribuídos de toda a MRO, com o slot
privado lido pelo nome que o Python guarda. O __weakref__ fica de fora. O
teste cobre o slot da classe, o herdado, o privado e o __dict__ declarado
como slot, em três sementes.
…dono

O método ligado repassa o __code__ da função e entrava por ele, sem o objeto
dono: obj.metodo e partial(obj.metodo) assinavam igual com outro estado. O
objeto staticmethod, que é o que se tem ao citar o método estático no corpo
do próprio modelo, entrava pelo repr, e editar o corpo não mudava nada.

O método ligado entra pela função e pelo dono, a classe pelo nome e a
instância pelos atributos, com a mesma guarda de ciclo. O staticmethod e o
classmethod entram pela função que embrulham.
O guia de desempenho, em português e em inglês, e a entrada do CHANGELOG
dizem que o objeto chamável entra também pelos atributos em __slots__, e o
método ligado a um objeto, pelo código do método e pelos atributos do objeto.
A leitura do estado do objeto chamável passava pela busca normal de atributo. No objeto só com __slots__, o nome __dict__ caía no __getattr__ da classe, e o None que ele devolvia derrubava a assinatura com TypeError; com checkpoint_path, o dataframeit() falhava antes da primeira linha. O slot sombreado na subclasse por property que levanta propagava a exceção, o slot não atribuído entrava com o valor inventado pelo __getattr__, e o __getattr__ que levanta KeyError derrubava a leitura do __code__.

Agora o __dict__ só entra quando é dict, o slot é lido pelo descritor que a classe criou para ele, e as leituras de __dict__, __code__ e __qualname__ tratam qualquer exceção como ausência do atributo.
A frase dizia que o método ligado entra pelos atributos do objeto; quando o dono é a classe, no classmethod, ele entra só pelo nome dela.
O Python tira os sublinhados do começo do nome da classe ao guardar o slot privado (o __peso de _Regra fica em _Regra__peso). Nenhum teste tinha esse caso, e a leitura do slot pelo nome sem tirar os sublinhados passava na suíte.
A guarda de ciclo olha só o caminho da descida, e o objeto alcançado por vários ramos era descido de novo em cada um: no grafo em que cada nível aponta duas vezes para o de baixo, o tempo dobrava a cada nível (31 s na profundidade 20).

A forma do objeto fora de ciclo não depende do caminho, e agora é calculada uma vez por assinatura e reaproveitada. O que separa esse objeto do que está num ciclo é o marcador mais alto da subárvore dele: no objeto do ciclo, o marcador aponta para o próprio objeto ou para cima dele, e a forma dele continua refeita a cada caminho. A forma produzida não muda.
O __dict__ só entra no estado quando é dict. Os casos de busca de atributo própria cobriam o None e o KeyError, e aceitar qualquer valor que não fosse None passava na suíte; o __getattr__ que devolve texto vazio, padrão comum de objeto de configuração, derrubava a assinatura nessa leitura.
@bdcdo

bdcdo commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Revisão independente (rodadas 1 a 4)

Subagentes revisores novos a cada rodada, sem o corpo do PR, em worktree própria, com prova do vermelho, mutação das guardas e caso fora da tabela. Passagens: e979bcf7 (rodada 1) → c9a796ba (rodada 2) → 4c84d3b4 (rodada 3) → c2c269b6 (correção da rodada 3). Os cinco hashes fixos do teste de compatibilidade são iguais nas quatro passagens, e os checkpoints gravados por c9a796ba com função, Enum, partial, objeto só com __dict__ e objeto com __slots__ retomam sem aviso em c2c269b6.

Corrigidos:

  • Rodada 1: objeto chamável com ciclo no estado (RecursionError) entra com marcador de ciclo por caminho; __slots__ do objeto chamável entram na assinatura, percorrendo o MRO; método ligado entra pelo código e pelo dono (o objeto pelos atributos, a classe do classmethod só pelo nome).
  • Rodada 3, A1: objeto cuja busca de atributo própria (__getattr__) devolve None ou "" para __dict__ ou __code__ derrubava a assinatura com TypeError. Corrigido em d9a0f9fd e c2c269b6: o __dict__ só entra quando é dict, os slots são lidos pelo descritor da classe, e a leitura de __code__ e __qualname__ não levanta. Vermelho no head: 6 reprovados contra o src de 4c84d3b4.
  • Rodada 3, A2 (parcial): objeto alcançado por vários ramos era descido uma vez por ramo. Em 4b530aa4 o objeto fora de ciclo entra por uma descida só: num DAG compartilhado de profundidade 12, 13 leituras de estado em vez de 8191; na profundidade 20, de 31 s e 3 GB para 3,4 s e 0,96 GB. Hashes iguais antes e depois.
  • Nits: docs e CHANGELOG sobre o classmethod ligado; teste do slot privado em classe cujo nome começa com sublinhado.

Mutação: das 18 mutações da rodada 3 adaptadas ao código final, 16 morrem; sobrevivem n (equivalente, como antes) e q (slot sem valor entra como None, quase equivalente). Das 12 novas (sem cache, cache sem condição, condição estrita, marcador não propagado, __dict__ sem checar o tipo, slot por getattr, leituras sem proteção), todas morrem. Gates verdes; suíte unit 1669 passados e 129 pulados, codex 1463 passados e 367 pulados; cobertura combinada 100%.

Em aberto, decisão de desenho (fica para o Bruno): a forma assinada do estado de um chamável é por caminho, e um conjunto de chamáveis em registro mútuo (cada um referenciando todos os outros) continua exponencial, porque todos estão no ciclo e a forma de cada um depende do caminho: 8 objetos geram 27 MB de forma em 0,9 s; 9 levam 12 s e 1,7 GB; 10 passam de 12 GB. A base (main) não assina o estado e não tem esse custo. Alternativa prototipada pelo corretor: trocar a forma de um objeto aninhado pelo hash dela acima de um teto de nós, o que preserva os hashes de todos os casos atuais e muda só os pesados; a outra saída é aceitar o custo como limite documentado. O PR não entra na fila de merge até essa decisão.

Preexistentes, para issue: json_schema_extra do model_config com conjunto de texto segue instável; lista ou dict que contém a si mesmo no estado levanta RecursionError; subclasse de int chamável entra sem o valor e NamedTuple chamável sem o corpo do __call__; DataFrame no estado entra pelo repr truncado.

Eixos não executados: Windows e macOS só pelo CI; Python 3.11 e 3.13 locais, 3.14 só pelo CI.

@bdcdo

bdcdo commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Evidência

Linux 6.5 x86_64, Python 3.11.14, pandas 3.1.0, pydantic 2.14.2. Dois ambientes, como os jobs do CI: unit (--extra dev --extra all --extra search-all) e codex (--extra dev --extra codex). O ramo ainda não tem a correção da #269, e por isso a suíte do ramo também rodou com pandas 3.0.6. Gates em Python 3.13.11.

<base> é a main em 4b70c20e; <ramo> é c2c269b6. Cada comando roda com PYTHONPATH=<arvore>/src, conferido por dataframeit.__file__.

repro_assinatura.py imprime os 16 primeiros caracteres de _run_signature para três modelos: Pedido, com field_condition(lambda d: d.get("tipo_cliente") == "vip", depends_on="tipo_cliente"); Cadastro, com {"condition": {"field": "tipo", "in": {"pf", "mei", "autonomo", "rural"}}}; e Simples, sem função nem conjunto. repro_ponta_a_ponta.py roda dataframeit com checkpoint_path, batch_size=1 e um agente simulado; o primeiro processo para por ProviderUsageLimitError depois da 3ª chamada, e o segundo, com outra semente, retoma o mesmo arquivo.

Caso na base (4b70c20)

$ for s in 0 1 2; do PYTHONHASHSEED=$s python repro_assinatura.py; done
seed=0 Pedido=7570247808d26e79 Cadastro=917b4df96b7b31fe Simples=93d2da8b9028f608
seed=1 Pedido=33617104812ddac4 Cadastro=471aa1c3e9e6734f Simples=93d2da8b9028f608
seed=2 Pedido=895265e97c9c9b77 Cadastro=db16dc5c8a0fb996 Simples=93d2da8b9028f608
$ PYTHONHASHSEED=0 python repro_assinatura.py Pedido   # duas vezes
Pedido=e76b099ad8f711b7
Pedido=ce881634e090935d
$ PYTHONHASHSEED=1 python repro_ponta_a_ponta.py Pedido Pedido.csv 3 && PYTHONHASHSEED=3 python repro_ponta_a_ponta.py Pedido Pedido.csv 100
chamadas=4
aviso: Execução interrompida: ProviderUsageLimitError: cota. 1 linha(s) ficaram sem status. Para retomar, rode de nov
chamadas=4
aviso: O checkpoint em Pedido.csv foi gravado com outra configuração (prompt, provider, modelo, model_kwargs, schema,
$ PYTHONHASHSEED=1 python repro_ponta_a_ponta.py Cadastro Cadastro.csv 3 && PYTHONHASHSEED=3 python repro_ponta_a_ponta.py Cadastro Cadastro.csv 100
chamadas=4
aviso: Execução interrompida: ProviderUsageLimitError: cota. 1 linha(s) ficaram sem status. Para retomar, rode de nov
chamadas=4
aviso: O checkpoint em Cadastro.csv foi gravado com outra configuração (prompt, provider, modelo, model_kwargs, schem

A main em 071ebf90, que entrou durante a medição, dá o mesmo: três assinaturas de Pedido e de Cadastro nas sementes 0, 1 e 2, e a retomada recusada com o mesmo aviso.

Caso no ramo (c2c269b)

$ for s in 0 1 2; do PYTHONHASHSEED=$s python repro_assinatura.py; done
seed=0 Pedido=cd1a025684f8fb97 Cadastro=683bef3b4c421af5 Simples=93d2da8b9028f608
seed=1 Pedido=cd1a025684f8fb97 Cadastro=683bef3b4c421af5 Simples=93d2da8b9028f608
seed=2 Pedido=cd1a025684f8fb97 Cadastro=683bef3b4c421af5 Simples=93d2da8b9028f608
$ PYTHONHASHSEED=0 python repro_assinatura.py Pedido   # duas vezes
Pedido=cd1a025684f8fb97
Pedido=cd1a025684f8fb97
$ PYTHONHASHSEED=1 python repro_ponta_a_ponta.py Pedido Pedido.csv 3 && PYTHONHASHSEED=3 python repro_ponta_a_ponta.py Pedido Pedido.csv 100
chamadas=4
aviso: The 'mode.chained_assignment' option is deprecated. It has had no effect since pandas 3.0, which removed the S
aviso: Execução interrompida: ProviderUsageLimitError: cota. 1 linha(s) ficaram sem status. Para retomar, rode de nov
chamadas=1
$ PYTHONHASHSEED=1 python repro_ponta_a_ponta.py Cadastro Cadastro.csv 3 && PYTHONHASHSEED=3 python repro_ponta_a_ponta.py Cadastro Cadastro.csv 100
chamadas=4
aviso: The 'mode.chained_assignment' option is deprecated. It has had no effect since pandas 3.0, which removed the S
aviso: Execução interrompida: ProviderUsageLimitError: cota. 1 linha(s) ficaram sem status. Para retomar, rode de nov
chamadas=1

O aviso do mode.chained_assignment é o do pandas 3.1 que a #269 corrigiu na main; no head mesclado com a main (e7f62269) ele some, e as assinaturas e a retomada saem iguais às de cima. A assinatura de Simples é 93d2da8b9028f608 nas três árvores.

Casos que a mudança afirma cobrir

"Estável" quer dizer a mesma assinatura com PYTHONHASHSEED 0 e 1 (e 2, nos testes de sementes), cada semente num processo. Os nomes entre crases são os casos dos scripts de revisão (casos3.py, casos.py) ou os testes de tests/test_assinatura_estavel.py, rodados contra o src de cada árvore.

Caso Esperado Ramo (c2c269b) Base (4b70c20)
field_condition com lambda (Pedido), sementes 0, 1 e 2 1 assinatura 1 (cd1a0256…) 3
set de 4 textos em condition (Cadastro), sementes 0, 1 e 2 1 assinatura 1 (683bef3b…) 3
interrompida depois de 3 linhas e retomada em outro processo, com outra semente 1 chamada, sem aviso 1, sem aviso 4, com o aviso de outra configuração
mesmo modelo criado duas vezes; partial e objeto chamável criados duas vezes mesma assinatura igual diferente nos 3
método ligado como condição (o objeto pelos atributos), sementes 0, 1 e 2 estável estável instável
método ligado: atributo do objeto muda (metodo-ligado-estado), corpo muda (metodo-ligado-corpo) estável, e muda estável, muda nos 2 instável nos 2
classmethod ligado: corpo muda (classmethod-ligado-corpo, da-classe-mudado) estável, e muda estável, muda estável, igual (o código não entra)
classmethod ligado: a classe dona vai para outro módulo mesma assinatura (a classe entra pelo nome) igual diferente
staticmethod como condição e pela classe (staticmethod-objeto-condicao, staticmethod-pela-classe) estável, e muda com o corpo estável, muda instável
functools.partial: de partial, de método ligado, com conjunto de texto nos argumentos, de lambda estável, e muda com corpo ou argumento estável, muda nos 4 instável nos 4
partial: corpo da função, keyword, argumento; objeto: corpo do __call__, estado assinatura diferente diferente nos 5 diferente (pelo endereço)
objeto com __slots__: slot próprio, herdado, privado, privado em classe _Regra, __dict__ declarado como slot estável, e muda com o slot estável nos 6; muda nos 6 instável; "muda" só porque o endereço muda a cada objeto
__slots__ = "peso" (texto, não tupla) estável, e muda estável, muda instável
ciclo: self.eu = self, ciclo de dois e de três objetos, ciclo por lista e por dict estável, sem RecursionError, e muda com o alvo estável, muda nos 5 instável nos 5
par em ciclo em dois atributos contra dois pares separados (par-ciclico-compartilhado-vs-separado) mesma assinatura igual (d7315ee7…) diferente, instável
par em ciclo, alvo de um deles muda assinatura diferente diferente, estável instável
mesmo objeto em dois atributos contra dois objetos iguais mesma assinatura igual diferente
__getattr__ que devolve None ou "", com __slots__ (nula, vazia); __getattr__ que devolve dict; slot sombreado por property que levanta assina sem levantar, estável, e muda com o slot estável e muda nos 4; contra o src de 4c84d3b4, os 5 testes desses casos reprovam com TypeError, KeyError ou ValueError instável nos 4
__getattr__ que levanta KeyError assina sem levantar estável, muda instável
DAG de profundidade 12, cada nível apontando duas vezes para o de baixo: leituras de estado 13 13 (13 objetos); 4c84d3b4, sem a memoização: 8191 não se aplica (a base assina pelo repr)
15 variações do modelo com condição callable (corpo, operador e método da lambda, nome da função, depends_on, conjunto, Enum, anotação, docstring, model_config, aninhado) e 12 atributos do campo assinatura diferente diferente em todos diferente em todos
os 5 hashes fixos de compatibilidade (Modelo, Empresa, ConjuntoDeInteiros, ExtraEmFuncao, SemSchema) iguais em e979bcf7, c9a796ba, 4c84d3b4 e c2c269b6 literais idênticos nos 4 commits (resumo 52d7b183…); o teste passa contra o src de cada um o teste passa contra o src da base
checkpoint gravado por c9a796ba (semente 0), retomado em c2c269b6 (semente 1): função, Enum com conjunto, sem condição, partial, objeto com __dict__, objeto com __slots__ = ("__dict__",) retoma sem aviso os 6 retomam só as linhas que faltavam, sem aviso
o mesmo, com método ligado e com __getattr__ que devolve None recomeça com aviso (o dono e o slot entram agora) recomeça com o aviso de outra configuração
checkpoint gravado por e979bcf7 retomado em c2c269b6: função, Enum com conjunto, sem condição retoma sem aviso os 3 retomam sem aviso
checkpoint gravado pela base retomado em c2c269b6: função; Enum com conjunto; sem condição recomeça, recomeça, retoma recomeça com aviso, recomeça com aviso, retoma sem aviso
no ramo, gravado e retomado com o mesmo método ligado, a mesma auto-referência, o mesmo __getattr__ retoma os 3 retomam sem aviso os 2 medidos recomeçam com aviso
no ramo, método ligado, slot e staticmethod com o alvo trocado de vip para comum recomeça os 3 recomeçam com aviso
registro mútuo, cada regra guardando a lista de todas: 8 chamáveis limite conhecido 27 MB de JSON assinado, 0,9 s não medido
idem, 9 chamáveis limite conhecido 9,7 a 10,6 s, pico de 1,67 GB, estável e muda 0,0 s, instável
DAG compartilhado de profundidade 20 limite conhecido 3,0 a 3,3 s, pico de 0,96 GB, estável e muda; 4c84d3b4: 29,8 s e 2,99 GB 0,0 s, instável

No registro mútuo, todo objeto está no ciclo e a forma de cada um depende do caminho por onde se chegou a ele, então a memoização não reaproveita nada: o JSON assinado cresce cerca de 8 vezes por chamável (3,4 MB com 7, 27 MB com 8). Com 10 chamáveis a assinatura passa de 12 GB, como a rodada de revisão anterior mediu.

Testes executados e pulados

base (4b70c20) ramo (c2c269b) main nova (071ebf9) head mesclado (e7f6226)
unit, pandas 3.1.0 1907 passados, 138 pulados 1628 passados, 41 reprovados, 129 pulados 1931 passados, 138 pulados 2001 passados, 138 pulados
unit, pandas 3.0.6 1669 passados, 129 pulados
codex, pandas 3.1.0 1676 passados, 404 pulados 1463 passados, 367 pulados 1700 passados, 404 pulados 1770 passados, 404 pulados
codex, pandas 3.0.6 1463 passados, 367 pulados
cobertura de ramos combinada, coverage report --fail-under=100 100% (3455 linhas, 1348 ramos) 100% (3391 linhas, 1352 ramos), nos dois pandas 100% (3571 linhas, 1398 ramos)

O ramo parte de b48bd034, e por isso tem menos testes que a base: os dos PRs mesclados depois não estão nele. As 41 reprovações do ramo com pandas 3.1.0 estão todas em tests/test_entrada_e_saida.py, com Pandas4Warning do mode.chained_assignment, que a #269 corrigiu na main; com pandas 3.0.6 elas passam, e no head mesclado também. No head mesclado, cada job tem 70 testes a mais que a main de que ele parte, que são os de tests/test_assinatura_estavel.py.

Testes novos contra o src da base: 14 dos 70 reprovam. Doze porque a base assina pelo repr, com o endereço do objeto: as sementes dos modelos, a retomada em outro processo, o mesmo modelo criado duas vezes, partial e objeto criados duas vezes, o ciclo entre sementes, o mesmo objeto em dois atributos, o par em ciclo, os slots entre sementes, o objeto com busca de atributo própria entre sementes, o método entre sementes e a classe dona movida de módulo. Um porque o classmethod ligado entra sem o código (da-classe-mudado, assinaturas iguais). Um porque o teste de contagem substitui core._slot_values, que a base não tem. Os 56 que passam na base são os de compatibilidade e os de "a mudança muda a assinatura", que a base já cumpre porque o endereço muda a cada objeto.

Com -W error, tests/test_assinatura_estavel.py passa inteiro em 3.11.14 e em 3.13.11 (70 passados). Gates em 3.13.11 no ramo: ruff format --check src tests, ruff check src tests, ty check, vulture e deptry src saem com 0. O CI do head mesclado e7f62269 passa em todos os jobs.

Risco: a assinatura passa a incluir o código e o estado dos chamáveis das condições, e o checkpoint gravado por versão anterior com chamável ou conjunto de texto na condição é recusado com aviso e a execução recomeça; o custo da assinatura cresce com o estado alcançável a partir dos chamáveis e é exponencial num registro mútuo grande (9 chamáveis, 10 s e 1,7 GB). Para desfazer, reverter o commit do squash.

SHA revisado: c2c269b

@bdcdo

bdcdo commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Decisão sobre o achado A2 da revisão independente

O Bruno liberou o merge com a forma por caminho como está. A assinatura continua reproduzível e independente do endereço de memória, e o custo exponencial do registro mútuo (cada regra guardando a lista de todas as outras) vira issue própria, aberta logo depois do merge, com a alternativa prototipada na revisão (hash da forma acima de um teto de nós). Os quatro achados preexistentes também viram issues.

Head de merge: e7f62269, que mescla a main em 071ebf90 sobre o SHA revisado c2c269b6 sem conflito e sem mudar o diff de src/, tests/ e docs/.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assinatura do checkpoint muda a cada processo com field_condition ou set em condition

1 participant