Repository navigation
Assinatura do checkpoint é a mesma em todo processo com condição callable ou conjunto - #267
Conversation
… 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.
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: Corrigidos:
Mutação: das 18 mutações da rodada 3 adaptadas ao código final, 16 morrem; sobrevivem 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 ( Preexistentes, para issue: Eixos não executados: Windows e macOS só pelo CI; Python 3.11 e 3.13 locais, 3.14 só pelo CI. |
# Conflicts: # CHANGELOG.md
EvidênciaLinux 6.5 x86_64, Python 3.11.14, pandas 3.1.0, pydantic 2.14.2. Dois ambientes, como os jobs do CI:
Caso na base (4b70c20)A Caso no ramo (c2c269b)O aviso do Casos que a mudança afirma cobrir"Estável" quer dizer a mesma assinatura com
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
O ramo parte de Testes novos contra o Com 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 |
Decisão sobre o achado A2 da revisão independenteO 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: |
O quê
Com
checkpoint_path, um modelo com condição callable (field_condition) ou com conjunto numacondition({"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 mesmocheckpoint_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 comPYTHONHASHSEEDfixo.As duas causas estão em
_run_signature. A função da condição fazmodel_json_schema()falhar, e o fallbackrepr(questions.model_fields)leva o endereço dela (<function Pedido.<lambda> at 0x7f...>); o_stable_reprnão o tira, porque ojson.dumpssó chama odefaultpara objeto não serializável, e o repr já chega como texto. O conjunto passa peloto_jsonable_pythondo Pydantic e vira lista na ordem de iteração, que para texto muda com a semente de hash: cinco sementes deram cinco assinaturas.Como
json_schema_extrade 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_extraetc.). Na anotação, o modelo aninhado entra pelo nome, pela docstring e pelomodel_config, e oEnum, pelos membros. O conjunto entra em ordem, pelo_stable, que ganhou um parâmetro para converter as folhas.frozensetque o compilador gera parad.get("tipo") in {"vip", "ouro"}também é ordenada; sem isso, a própria lambda variava com a semente.main: pelo JSON Schema ou, quando ele falha, pelo repr dos campos.Itens da issue
field_condition: corrigido. A assinatura é a mesma comPYTHONHASHSEED0, 1 e 2.setemcondition: corrigido, também com o conjunto num campo aninhado.Decisões
json_schema_extrade 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 namain(pydantic 2.14.1 e 2.12.0, sementes 0 e 7) para cinco modelos: um campo só;Literal,Enum, modelo aninhado econditionem 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_extraque é 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 comPYTHONHASHSEEDfixo. Neste último caso, o checkpoint gravado antes da atualização deixa de ser retomado uma vez, o que o CHANGELOG registra.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.resume=Falseou outrocheckpoint_pathpara mudar a condição por esses caminhos. Um callable sem código Python, comofunctools.partialou um objeto com__call__, entra pelo repr sem o endereço.Fora deste PR
O
json_schema_extrado 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 odefaultdojson.dumpso serializa pelo repr. A biblioteca não lê chave nenhuma dojson_schema_extrado 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 namain; 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 doEnum, argumento da anotação, docstring,model_confige campo do modelo aninhado, e cada atributo do campo).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