From faab776f6d5541ed37f4d488d3dfb428f40bbd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:44:36 -0300 Subject: [PATCH 01/18] =?UTF-8?q?Especifica=20a=20spec=20005:=20escrita,?= =?UTF-8?q?=20ASCII,=20exibi=C3=A7=C3=A3o=20e=20agrupamento=20de=20variant?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005 e ADRs 0010 (regras de agrupamento), 0011 (correspondência sem acento) e 0012 (transliteração para ASCII); atualiza o roadmap. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-regras-de-agrupamento.md | 34 ++++++ docs/adr/0011-correspondencia-sem-acento.md | 26 +++++ docs/adr/0012-transliteracao-ascii.md | 27 +++++ docs/specs/005-variantes/spec.md | 120 ++++++++++++++++++++ docs/specs/ROADMAP.md | 4 +- 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0010-regras-de-agrupamento.md create mode 100644 docs/adr/0011-correspondencia-sem-acento.md create mode 100644 docs/adr/0012-transliteracao-ascii.md create mode 100644 docs/specs/005-variantes/spec.md diff --git a/docs/adr/0010-regras-de-agrupamento.md b/docs/adr/0010-regras-de-agrupamento.md new file mode 100644 index 0000000..b9ef8d5 --- /dev/null +++ b/docs/adr/0010-regras-de-agrupamento.md @@ -0,0 +1,34 @@ +# 0010. Agrupamento de variantes em duas fases, sem a P460 e com a P1889 como corte + +## Status + +Aceito (2026-10-12) + +## Contexto + +A spec 005 preenche `group_id` (spec 001, R3.2) a partir da tabela `variant_links` (ADR 0009) e das próprias observações. O agrupamento precisa ser determinístico e explicável: quem lê `given_names.csv` deve conseguir dizer por que dois nomes estão no mesmo grupo. + +O fecho transitivo das ligações publicadas pelo Wikidata não serve como grupo. Medido em 2026-10-12 sobre os nós com observação no lote real (811.117 nós do Wikidata): + +- Com `same_item` e `transliteration`, o maior componente tem 1.124 nomes: romanizações como `Chen`, `Chan` e `Ang` são transliteração de vários caracteres han diferentes e os ligam entre si. +- Somando `said_to_be_the_same` (P460), o maior componente tem 15.892 nomes (de `Aadne` a `Zygmunt`), e 14.039 mesmo pulando as junções proibidas por `different_from` (P1889). A P460 liga cognatos de línguas diferentes, e nomes como `Elisa`, `Lisa` e `Ελισάβετ` têm mais de 500 ligações cada. +- Limitar a P460 a nomes com poucas ligações não estabiliza: com até 8 ligações o maior grupo tem 30 nomes e `Ivanov` fica sozinho; com até 16, o maior tem 2.976. + +## Decisão + +- Os nós são os pares (`kind`, `name_key`) com observação. As ligações se juntam aos nós pela chave, nunca pelo texto do nome, e são tratadas como não dirigidas. Ligação com ponta sem observação é ignorada. +- Prenome e sobrenome nunca se juntam; `group_id` é único dentro de cada `kind`. +- **Fase 1, mesmo nome:** `same_item` e, só em sobrenomes, `other_gender` (P5278: `Иванов` e `Иванова` são o mesmo sobrenome flexionado). Em prenomes, `other_gender` (P1560: `Paulo` e `Paula`) não junta. +- **Fase 2, anexação inequívoca,** calculada sobre os grupos da fase 1: a transliteração junta o destino ao grupo de origem só se todas as transliterações que chegam ao destino vêm do mesmo grupo; a regra sem acento (ADR 0011) junta um nome sem acento aos nomes com o mesmo `ascii` só se todos eles estão no mesmo grupo. +- `said_to_be_the_same` (P460) não junta. Ela continua em `variant_links` para quem quiser cognatos. +- `different_from` (P1889) proíbe que dois nós fiquem no mesmo grupo. As junções são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode); a que violaria a proibição é pulada. +- `group_id` é o menor `name_key` do grupo em ordem de código Unicode; nome sem variante é seu próprio grupo. +- A transliteração publicada como qualificador de um rótulo, que a spec 004 liga a todos os rótulos do item, não é restrita: os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e a fase 2 só olha para o grupo. + +## Consequências + +- Os grupos são pequenos (o maior, na simulação sobre o lote real, tem 31 nomes) e cada junção tem uma causa nomeável: mesmo item, mesmo sobrenome no outro gênero, transliteração não ambígua ou fonte que remove acentos. +- Cognatos (`John`, `Johann`, `Juan`) e grafias alternativas ligadas só pela P460 (`Ivanov` e `Iwanow`) ficam em grupos separados. Quem precisar delas usa `variant_links`. +- Romanizações ambíguas e nomes sem acento ambíguos (`Ines` entre `Inès` e `Inés`) ficam fora de qualquer grupo com variantes, o que perde junções corretas em troca de não criar erradas. +- `group_id` muda quando entra no grupo um nome com `name_key` menor; ele identifica o grupo dentro de uma release, não entre releases. +- Uma relação nova do Wikidata, ou outra fonte com `links`, só passa a juntar nomes com mudança desta decisão. diff --git a/docs/adr/0011-correspondencia-sem-acento.md b/docs/adr/0011-correspondencia-sem-acento.md new file mode 100644 index 0000000..afd5125 --- /dev/null +++ b/docs/adr/0011-correspondencia-sem-acento.md @@ -0,0 +1,26 @@ +# 0011. Nome sem acento só se junta ao acentuado quando a fonte remove acentos + +## Status + +Aceito (2026-10-12) + +## Contexto + +A Statistics Canada publica `MARIE-EVE` e `JOSEE` (nota 7 da tabela 17-10-0147-01), o INE remove acentos por norma ("eliminando los acentos", nota metodológica dos nomes) e a SSA não tem nenhum caractere fora do ASCII em 104.819 nomes. Os mesmos nomes aparecem com acento no Wikidata e no INSEE. Sem regra, `Marie-Eve` (CA) e `Marie-Ève` ficam em grupos diferentes (issue #4). + +Juntar quaisquer dois nomes com a mesma forma sem acento erra em línguas em que o acento distingue nomes: `Đức` e `Đúc` em vietnamita, `Aleš` e `Alés`, `Abrahám` e `Ábrahám`. Esses pares vêm de fontes que publicam acentos, e a falta de acento num deles não é perda de informação. + +## Decisão + +- `sources.yaml` ganha o campo opcional `strips_accents` (padrão `false`). Ele só é `true` com evidência registrada na spec: documentação da fonte que diz que os acentos são removidos, ou medição no arquivo completo de que nenhum nome tem letra acentuada apesar de nomes de línguas que a usam. +- A correspondência sem acento só parte de um nome latino atestado por fonte com `strips_accents: true`. Ele se junta aos nomes do mesmo `kind` com o mesmo `ascii` (ADR 0012) apenas se todos esses nomes já estão no mesmo grupo (ADR 0010, fase 2). +- Nomes acentuados nunca se juntam entre si só por terem o mesmo `ascii`. +- Hoje: `ca-statcan-births`, `es-ine-residentes` e `us-ssa-births`. + +## Consequências + +- `Marie-Eve` e `Marie-Ève`, `Josee` e `Josée`, `Garcia` e `García` ficam no mesmo grupo; as contagens continuam em linhas separadas. +- Nome sem acento com mais de uma leitura acentuada em grupos diferentes (`Ines`: `Inès`, `Inés`) não se junta a nenhuma; medido na simulação: 2.883 casos contra 14.394 junções. +- O INE mantém `ñ` e `ç`; como `ascii` também os remove, `Begoña` do INE se junta a `Begona` quando esta é a única leitura, o que é aceito como variante. +- Se a SSA tiver nomes sem acento por escolha de quem registrou e não por limitação do sistema, a junção ainda liga grafias do mesmo nome; o custo de errar é baixo. +- Fonte nova que remove acentos precisa da evidência na spec para entrar na regra; sem ela, seus nomes ficam sem junção por acento. diff --git a/docs/adr/0012-transliteracao-ascii.md b/docs/adr/0012-transliteracao-ascii.md new file mode 100644 index 0000000..21e5489 --- /dev/null +++ b/docs/adr/0012-transliteracao-ascii.md @@ -0,0 +1,27 @@ +# 0012. Transliteração para ASCII pelo ICAO Doc 9303, sem forma parcial + +## Status + +Aceito (2026-10-12) + +## Contexto + +O campo `ascii` (spec 001, R1) era a remoção de diacríticos em escrita latina e vazio nas demais. A revisão da spec 001 (issue #4) mostrou que ligaduras (`Dž`), apóstrofos curvos e letras sem decomposição (`ə`, `ŋ`) eram descartados em silêncio, gerando formas que não correspondem ao nome. O agrupamento sem acento (ADR 0011) depende de `ascii`, e o Wikidata traz 14.682 nomes em cirílico e 2.645 em grego. + +Uma biblioteca de transliteração genérica (Unidecode, ICU) seria dependência nova de runtime e daria resultados sem norma citável. O ICAO Doc 9303 (parte 3, seção 6, 8.ª edição, 2021) tem tabelas recomendadas para nomes em documentos de viagem: latim multinacional (tabela A) e cirílico (tabela B); não tem grego. + +## Decisão + +- Latim: NFKD, remoção das marcas combinantes e tabela fechada das letras sem decomposição, com os valores da tabela A do ICAO e uma letra só onde ela dá alternativa (`Ä` vira `a`, não `ae`), para que a forma sem acento das fontes que removem acentos coincida. +- Cirílico: tabela B do ICAO na forma padrão de cada letra, sem as exceções por língua (o dataset não sabe a língua do nome); `ь` omitido; `ћ`, ausente, vira `c` como o `ć` sérvio. +- Apóstrofos e hífens tipográficos viram `'` e `-`; ponto médio e caracteres de formatação são omitidos. +- Se algum caractere não tem transliteração, `ascii` fica vazio. Não se emite forma parcial. +- Grego, hangul, kana e as demais escritas ficam vazias até uma spec própria escolher a norma (ELOT 743 para o grego, romanização revisada para o coreano, Hepburn para o japonês). +- Nenhuma dependência nova. + +## Consequências + +- `ascii` passa a ter valor para nomes em cirílico (`Иванов` vira `ivanov`, `Мария` vira `mariia`), e a forma difere de transliterações de outras normas publicadas no Wikidata (`Mariya`). +- Nome latino com letra fora da tabela tem `ascii` vazio; `validate` deixa de exigir `ascii` em escrita latina e passa a exigir só caracteres ASCII sem maiúsculas. +- Ucraniano e sérvio ficam com a forma russa das letras comuns (`Г` vira `g`, não `h`), como o ICAO faz quando a língua não é conhecida. +- Mudar a norma de uma escrita muda valores de `ascii` e pede versão menor de `SCHEMA_VERSION`. diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md new file mode 100644 index 0000000..68aa140 --- /dev/null +++ b/docs/specs/005-variantes/spec.md @@ -0,0 +1,120 @@ +# 005. Variantes + +Parte do [roadmap](../ROADMAP.md). Depende da [spec 003](../003-fontes-oficiais/spec.md) e da [spec 004](../004-wikidata/spec.md). Esquema: [spec 001](../001-esquema/spec.md). Decisões: [ADR 0010](../../adr/0010-regras-de-agrupamento.md) (regras de agrupamento), [ADR 0011](../../adr/0011-correspondencia-sem-acento.md) (correspondência sem acento), [ADR 0012](../../adr/0012-transliteracao-ascii.md) (transliteração para ASCII). Pendências herdadas: issues #4 e #31. + +Status: Em implementação. + +## Por quê + +A spec 001 deixou três campos para esta spec: `ascii` (só remoção de diacríticos em escrita latina, vazio nas demais), `group_id` (sempre vazio) e a forma de exibição (`name`) do índice, escolhida somando contagens de tipos e edições diferentes. A revisão da 001 (issue #4) apontou falhas em `detect_script` (`々`, `ー` e latim de largura total viram `Zzzz`; nome japonês com kanji e kana vira `Hira`), em `ascii_form` (ligaduras e dígrafos, apóstrofo curvo descartado, `ə` e `ŋ` somem) e na capitalização das fontes de caixa uniforme (`Maria Del Carmen`, `Joana D'Arc`, `Van Der Berg`). + +As fontes oficiais da Statistics Canada, do INE e da SSA publicam os nomes sem acento (`MARIE-EVE`, `JOSEE`, `JOSE`), e o Wikidata e o INSEE os publicam com acento (`Marie-Ève`, `José`). Sem agrupamento, o mesmo nome aparece em linhas sem relação entre si. A spec 004 gravou as ligações do Wikidata (`variant_links`), mas deixou para esta spec decidir quais delas juntam nomes e quais os separam. + +## Requisitos + +### R1. Escrita (`script`) + +`detect_script` continua devolvendo o código ISO 15924 predominante entre as letras (spec 001, R1), com estas correções: + +- **R1.1** Letras de largura total ou meia largura (`A`, `ア`) contam pela escrita da letra (`Latn`, `Kana`). +- **R1.2** `々` (marca de iteração) conta como `Hani`. `ー` (marca de som longo) e `ゝ`, `ゞ`, `ヽ`, `ヾ` não contam: herdam a escrita das letras vizinhas. +- **R1.3** Nome cujas letras são só de `Hani`, `Hira` e `Kana`, com pelo menos uma de `Hira` ou `Kana` e pelo menos duas dessas três, é `Jpan` (`さくら子`, `ゆかり`/`ユカリ` misturados). Nome com `Hang` e `Hani` e nenhuma outra é `Kore`. +- **R1.4** A tabela de escritas cobre também as que apareceram como `Zzzz` nos dados reais (medição de 2026-10-12 sobre o lote completo, 100 nomes): oriá (`Orya`), batak (`Batk`), ol chiki (`Olck`), brahmi (`Brah`), fenício (`Phnx`), siríaco (`Syrc`), tifinagh (`Tfng`), gótico (`Goth`), cherokee (`Cher`), silabário canadense (`Cans`), mandaico (`Mand`), copta (`Copt`), rúnico (`Runr`), warang citi (`Wara`), meetei mayek (`Mtei`), syloti nagri (`Sylo`), hieróglifos egípcios (`Egyp`), thaana (`Thaa`), n'ko (`Nkoo`) e vai (`Vaii`). Letra de escrita fora da tabela continua `Zzzz`. + +### R2. Forma ASCII (`ascii`) + +`ascii` é a transliteração do nome para ASCII em minúsculas, caractere a caractere, pelo método do ADR 0012: + +- **R2.1 Latim.** Decomposição NFKD (ligaduras e dígrafos de compatibilidade como `Dž`, `fi` e letras de largura total viram letras simples), remoção das marcas combinantes e a tabela de letras sem decomposição: `ß` `ss`, `ẞ` `ss`, `ø` `o`, `æ` `ae`, `œ` `oe`, `đ` `d`, `ð` `d`, `ł` `l`, `þ` `th`, `ı` `i`, `ħ` `h`, `ŧ` `t`, `ŋ` `n`, `ə` `e`, `ɛ` `e`, `ɔ` `o`, `ɣ` `g`, `ʙ` `b`. Os valores seguem a tabela A da seção 6 do ICAO Doc 9303 (parte 3, 8.ª edição, 2021) onde ela tem a letra, com a forma de uma letra só onde ela dá alternativa (`Ä` "AE or A" vira `a`), e as letras de alfabetos africanos ausentes do ICAO (`ə`, `ɛ`, `ɔ`, `ɣ`) usam a vogal ou consoante latina de mesma base. +- **R2.2 Cirílico.** A tabela B da mesma seção do ICAO Doc 9303, na forma padrão de cada letra (sem as exceções por língua, porque o dataset não sabe a língua do nome): `ж` `zh`, `х` `kh`, `ц` `ts`, `ч` `ch`, `ш` `sh`, `щ` `shch`, `ъ` `ie`, `ы` `y`, `э` `e`, `ю` `iu`, `я` `ia`, `ё` `e`, `й` `i`, `є` `ie`, `ї` `i`, `ђ` `d`, `ѕ` `dz`, `ј` `j`, `љ` `lj`, `њ` `nj`, `ќ` `k`, `ў` `u`, `џ` `dz`, `ґ` `g`, `ғ` `g`, `һ` `c`, `ѫ` `u`, `ѵ` `y` e as letras restantes do russo como na tabela. `ь` não tem transliteração e é omitido. `ћ`, ausente da tabela, vira `c`, como `ć` do alfabeto latino sérvio que lhe corresponde. +- **R2.3 Pontuação do nome.** Espaço, hífen e apóstrofo reto ficam. Apóstrofos tipográficos e modificadores (`’`, `‘`, `ʼ`, `ʻ`, `ʹ`, `ʾ`, `ʿ`, `` ` ``, `´`) viram `'`. Hífens e travessões Unicode (`‐`, `‑`, `‒`, `–`) viram `-`. O ponto médio (`·`, do catalão `l·l`) e caracteres de formatação (categoria `Cf`, como o ZWJ) são omitidos. +- **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias por enquanto (issue de acompanhamento). +- **R2.5 Validação.** `validate` deixa de exigir `ascii` não vazio em escrita latina e passa a exigir que `ascii` só tenha caracteres ASCII sem maiúsculas. + +### R3. Capitalização das fontes de caixa uniforme + +Quando a fonte tem `uniform_case: true` (spec 002, R1.3), depois da capitalização por palavra: + +- **R3.1** Palavra que não é a primeira do nome, separada por espaço, e que é uma das partículas `da`, `das`, `de`, `del`, `della`, `der`, `den`, `des`, `di`, `do`, `dos`, `du`, `van`, `von`, `y` vai para minúsculas: `Maria del Carmen`, `Van der Berg`, `Alves da Silva`, `Gabriel y Galan`. +- **R3.2** Os artigos `la`, `las`, `le`, `los` vão para minúsculas só logo depois de uma partícula de R3.1: `Abel de la Cruz`, `De la de las Heras`. Isolados ficam capitalizados, porque também são nomes (`Jia Le`, medido no INE). +- **R3.3** Palavra que não é a primeira e começa por `d'` ou `l'` fica com o prefixo em minúsculas e a letra seguinte em maiúscula: `Joana d'Arc`, `Francesc d'Assis`. Na primeira palavra o prefixo continua maiúsculo (`D'Arcy`). +- **R3.4** Partes ligadas por hífen não mudam (`Marie-Ève`, `Jean-De-Dieu` continua como a capitalização por palavra o deixa). +- **R3.5** Fontes com `uniform_case: false` continuam com a caixa publicada (`Da Silva` do Wikidata não muda). + +### R4. Forma de exibição (`name` agregado) + +A forma de exibição deixa de somar contagens de tipos, edições ou fontes que a agregação não usa (spec 001, R1.3 e R3.3). Entre as formas de um mesmo `name_key`: + +- **R4.1 Caixa publicada primeiro.** Se alguma observação vem de fonte com `uniform_case: false`, só as formas dessas observações concorrem; a capitalização gerada pelo pipeline nas fontes de caixa uniforme só vale quando não há outra. +- **R4.2 Por país** (`by_country`): vence a forma de maior soma de `count` entre as observações contadas na linha do país (as do `count_type`, da fonte `primary` e da edição escolhidos por R3.3 da spec 001); empate ou sem contagem, a forma com mais observações no país; persistindo, a menor em ordem de código Unicode. +- **R4.3 No índice** (`given_names` e `family_names`): vence a forma que é a exibida em mais países (contando só os países em que a forma exibida vem de fonte de caixa publicada, ou todos se R4.1 não restringiu); empate, a forma com mais observações; persistindo, a menor em ordem de código Unicode. Nenhuma contagem é somada entre países. +- **R4.4 Deduplicação.** O índice continua com uma linha por `name_key` (spec 001, R3.2): formas que diferem só na caixa (`Jo-Anne` e `Jo-anne`, `McKenzie` e `Mckenzie`) já são a mesma linha, e R4 decide qual forma ela mostra. Formas com `name_key` diferentes (`Marie-Eve` e `Marie-Ève`) continuam linhas próprias e são deduplicadas pelo `group_id` (R5), sem fundir contagens. + +### R5. Agrupamento de variantes (`group_id`) + +Cada linha de `given_names` e `family_names` recebe `group_id`. O agrupamento é determinístico, por `kind`, sobre o grafo cujos nós são os pares (`kind`, `name_key`) com observação, conforme o ADR 0010: + +- **R5.1 Junção pela chave.** Uma ligação de `variant_links` se liga às observações só por (`kind`, `name_key`) e (`target_kind`, `target_name_key`), nunca pelo texto de `name`: a ligação pode trazer uma forma que difere em caixa da forma exibida (`McKenzie` na ligação, `Mckenzie` no índice). Ligação com uma ponta sem observação (por exemplo, transliteração sem fonte que a ateste, 30.649 das 36.909 medidas) é ignorada. A direção publicada não importa: toda ligação usada é tratada como não dirigida. +- **R5.2 Tipos diferentes nunca se juntam.** Ligação com `kind` diferente de `target_kind` (`family_name_identical`, e as poucas `said_to_be_the_same`, `other_gender` e `different_from` entre prenome e sobrenome) não junta nada. `group_id` é único dentro de cada `kind`. +- **R5.3 Fase 1, mesmo nome.** Juntam: `same_item` (rótulos nativos do mesmo item) e, só em sobrenomes, `other_gender` (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. +- **R5.4 Fase 2, anexação inequívoca.** Calculada sobre os grupos da fase 1: + - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1. Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. + - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1. `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. +- **R5.5 Não juntam.** `said_to_be_the_same` (P460) não junta: o fecho transitivo dessas ligações forma um componente de 14.039 nomes mesmo com os cortes de R5.6 (medido nos dados reais, ADR 0010). Nomes que só diferem em acento, sem fonte que remove acentos, também não juntam. +- **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções das fases 1 e 2 são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. +- **R5.7 Identificador.** `group_id` é o menor `name_key` do grupo em ordem de código Unicode (`marie-eve` para `Marie-Eve` e `Marie-Ève`). Nome sem variante forma um grupo sozinho, e seu `group_id` é o próprio `name_key`. Sem `variant_links` (`build` sem `--links`), só a regra sem acento junta nomes. +- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata, 1.008.182 nós), a simulação destas regras anexa 3.347 transliterações (816 ambíguas ficam de fora) e 14.394 nomes sem acento (2.883 ambíguos ficam de fora), pula 292 junções por `different_from` e forma 35.759 grupos com mais de um nome, o maior com 31 (um sobrenome coreano escrito com hanjas diferentes no mesmo item do Wikidata). Os números finais da implementação vão para o pull request. + +### R6. Fontes que removem acentos (`strips_accents`) + +`sources.yaml` ganha o campo opcional `strips_accents` (booleano, padrão `false`): a fonte publica os nomes sem acento por regra própria, e não porque os nomes não têm acento. Só entra com evidência registrada aqui: + +| Fonte | Evidência | +|---|---| +| `ca-statcan-births` | Nota 7 da tabela 17-10-0147-01: os nomes são publicados sem acento (`MARIE-EVE`, `JOSEE`); medido: 0 de 18.999 nomes com caractere fora do ASCII | +| `es-ine-residentes` | Nota metodológica do INE (`nota_nombres.pdf`): "eliminando los acentos y permitiendo como caracteres válidos el apóstrofo y la cedilla, pero no la diéresis"; medido: 143.578 nomes, só `ñ`, `ç` e `·` fora do ASCII | +| `us-ssa-births` | Medido: 0 de 104.819 nomes com caractere fora do ASCII, inclusive os de origem espanhola e francesa (`Jose`, `Andre`, `Rene`) | + +As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikidata) e ficam com `false`. + +### R7. Contratos e manifesto + +- **R7.1** A spec 002 (R1) recebe uma nota: o contrato do adaptador tem a função opcional `links(files)`, definida pela spec 004 (R4.1). +- **R7.2** A spec 001 recebe notas em R3 e R5: a release pode trazer `variant_links.parquet` (spec 004, R4.6), `group_id` é preenchido por esta spec, e `ascii` segue o ADR 0012. +- **R7.3** A spec 002 (R2) recebe uma nota apontando para `strips_accents` (R6). +- **R7.4** A lista `sources` do `manifest.json` é a união das fontes das observações e das fontes das ligações. + +### R8. Versão + +`group_id` preenchido, `ascii` em cirílico, `Jpan` e `Kore` em `script` e a nova capitalização mudam valores das saídas sem remover nem renomear campo: `SCHEMA_VERSION` passa a `1.2.0` (spec 001, R5). + +### R9. Desempenho e memória + +- **R9.1** O agrupamento trabalha sobre os nós (`kind`, `name_key`), um `kind` por vez, com inteiros e as chaves que o índice já tem; não cria outra cópia das observações nem das ligações. +- **R9.2** `run --offline` com o lote real completo, e num único `run` com as 3 milhões de observações sintéticas de `tools/bench_pipeline.py` mais o Wikidata real, fica dentro de R8 da spec 002 (menos de 10 minutos e de 4 GB), e o pico de memória não passa do medido na master nas mesmas condições. Se passar, esta spec reduz o pico (issue #31). A medição vai para o pull request. + +### R10. Testes e CI + +- Testes sem rede, com tabelas pequenas escritas à mão e as amostras de `tests/fixtures/`. +- Cada item da issue #4 tem teste: empate e mistura de escritas, `Zzzz`, `々`, `ー`, latim de largura total, NFKD, apóstrofo curvo, `ə`, `ŋ`, partículas e `D'Arc`, e a forma de exibição que não soma tipos diferentes. +- O passo da CI que roda as fontes de `sources.yaml` sobre as amostras confere que `given_names.csv` tem `group_id` preenchido em todas as linhas. + +## Critérios de aceite + +1. `detect_script` devolve `Hani` para `佐々木`, `Kana` para `ユーリ`, `Latn` para `Anna`, `Jpan` para `さくら子`, `Kore` para `김金` e `Orya` para `ଦାସ`. +2. `ascii_form` devolve `dzenana` para `DŽenana`, `o'brien` para `O’Brien`, `aliyev` para `Əliyev`, `ngozi` para `Ŋgozi`, `ivanov` para `Иванов`, `mariia` para `Мария`, `dokovic` para `Ђоковић`, `pelleja` para `Pel·leja` e vazio para `Μαρία` e para um nome latino com letra fora da tabela. +3. Com `uniform_case: true`, `JOANA D'ARC` vira `Joana d'Arc`, `MARIA DEL CARMEN` vira `Maria del Carmen`, `VAN DER BERG` vira `Van der Berg`, `DE LA DE LAS HERAS` vira `De la de las Heras`, `JIA LE` vira `Jia Le` e `D'ARCY` vira `D'Arcy`. +4. Uma tabela de teste em que a forma `A` tem mais nascimentos somados de vários anos num país e a forma `a'` tem mais pessoas vivas em outro país mostra que a forma de exibição não soma `births` com `population`, e que a forma publicada vence a capitalização gerada. +5. Uma tabela de teste com ligações escritas à mão produz os grupos esperados: `Marie-Eve` (CA) com `Marie-Ève`; `Иванов`, `Иванова` e `Ivanov` (transliteração inequívoca) num grupo; `Paulo` e `Paula` separados; `Chen` sem grupo com `陈` e `沈`; `Ines` sem grupo; um par `different_from` separado mesmo ligado por `same_item` a um terceiro nome; nada junta prenome com sobrenome; P460 não junta. +6. `run --offline` sobre as amostras grava `given_names.csv` e `family_names.csv` com `group_id` em todas as linhas, `manifest.json` com `schema_version` `1.2.0` e a lista de fontes com as das ligações. +7. `strips_accents` é aceito em `sources.yaml` só como booleano, e as três fontes de R6 o têm `true`. +8. A medição de R9 é feita com os arquivos reais e vai para a descrição do pull request. +9. A CI roda verde no pull request, que fecha a issue #4. + +## Fora de escopo + +- Transliteração de grego, hangul, kana, árabe, hebraico, han e das demais escritas (issue de acompanhamento). +- Fundir linhas do índice ou contagens de nomes com `name_key` diferentes. +- Agrupar cognatos (`John`, `Johann`, `Juan`) pela P460, e qualquer comunidade ou corte por tamanho sobre ela (ADR 0010). +- `group_id` em `by_country/.csv` e um arquivo com as junções aplicadas. +- Restaurar acentos nos nomes das fontes que os removem. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index 467d128..fda2433 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -14,7 +14,7 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 3d | [`003d-statistics-canada`](003d-statistics-canada/spec.md) | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Mesclada | | 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Em PR | | 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Em PR | -| 5 | `005-variantes` | 003, 004 | Transliteração para ASCII, agrupamento de variantes (`group_id`), deduplicação | Planejada | +| 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4) | Em PR | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | -Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md). +Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md), [0010 regras de agrupamento](../adr/0010-regras-de-agrupamento.md), [0011 correspondência sem acento](../adr/0011-correspondencia-sem-acento.md), [0012 transliteração para ASCII](../adr/0012-transliteracao-ascii.md). From dfee58be9ec24248abba978cf6865497f8c920c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:45:41 -0300 Subject: [PATCH 02/18] Planeja a spec 005: plano e tarefas Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/plan.md | 76 ++++++++++++++++++++++++++++++ docs/specs/005-variantes/tasks.md | 78 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/specs/005-variantes/plan.md create mode 100644 docs/specs/005-variantes/tasks.md diff --git a/docs/specs/005-variantes/plan.md b/docs/specs/005-variantes/plan.md new file mode 100644 index 0000000..00f60e0 --- /dev/null +++ b/docs/specs/005-variantes/plan.md @@ -0,0 +1,76 @@ +# 005. Variantes: plano de implementação + +**Objetivo:** corrigir a escrita, a forma ASCII, a capitalização e a forma de exibição dos nomes (issue #4) e preencher `group_id` com um agrupamento determinístico sobre `variant_links` e as fontes que removem acentos. + +**Arquitetura:** `normalize.py` concentra as regras por nome: `detect_script` (R1), `ascii_form` com as tabelas do ADR 0012 (R2) e `normalize_name` com partículas (R3). `sources.py` aceita `strips_accents` (R6). `aggregate.py` escolhe a forma de exibição por país e no índice (R4) e recebe as fontes e as ligações em `name_index`. O módulo novo `variants.py` tem a função pura `group_ids(keys, ascii, unaccented, links, kind)` que devolve o `group_id` de cada chave (R5), com união e busca sobre inteiros. `cli._build` passa fontes e ligações a `name_index` e monta a lista de fontes do manifesto com as das ligações (R7.4). + +**Stack:** a mesma da spec 004, sem dependência nova. + +**Spec:** [`spec.md`](spec.md). ADRs: [`0009`](../../adr/0009-ligacoes-entre-variantes.md), [`0010`](../../adr/0010-regras-de-agrupamento.md), [`0011`](../../adr/0011-correspondencia-sem-acento.md), [`0012`](../../adr/0012-transliteracao-ascii.md). Pendências herdadas: issues #4 e #31. + +## Restrições globais + +- Nenhuma dependência nova; tabelas de transliteração no código, com a fonte (ICAO Doc 9303, parte 3, seção 6) citada no módulo. +- Testes sem rede, com tabelas escritas à mão e as amostras existentes de `tests/fixtures/`; nenhuma amostra nova é necessária. +- O agrupamento não copia observações nem ligações: trabalha com índices inteiros sobre as chaves ordenadas do índice e percorre as ligações uma vez por `kind`. +- `SCHEMA_VERSION` passa a `1.2.0`. + +## Decisões de implementação + +| Decisão | Motivo | +|---|---| +| `group_ids` recebe listas paralelas (chaves ordenadas, `ascii`, marca de sem acento) e devolve uma lista | Função pura, testável com poucos nomes, e sem objeto por nó; o índice já tem as chaves ordenadas | +| União e busca com `list[int]` e compressão de caminho; proibições de `different_from` num `dict[int, set[int]]` só para as raízes que têm proibição | 191 mil P1889 viram 8 mil pares entre nós com observação; o resto do grafo não paga nada | +| A checagem de proibição percorre o menor conjunto e confere a raiz de cada nó | Mantém o custo proporcional às proibições, sem recalcular os conjuntos a cada junção | +| `group_id` é a primeira chave do grupo na lista ordenada | A lista já está em ordem de código Unicode; menor chave sem outra ordenação | +| A forma ASCII do nó é a de `ascii_form` sobre a forma exibida | Observações lidas de um Parquet antigo podem ter `ascii` da regra anterior; a forma exibida é a mesma que vai para o índice | +| `CountryRow` ganha o campo interno `published_case` (não sai no CSV) | O índice precisa saber se a forma exibida no país veio de fonte de caixa publicada (R4.3) sem reler as observações | +| `name_index(observations, country_rows, kind, sources=None, links=None)` | Chamadas existentes sem fontes e sem ligações continuam válidas: tratam toda fonte como de caixa publicada e sem remoção de acento, e não usam ligações | +| `validate` confere `ascii` só como ASCII sem maiúsculas | Recalcular `ascii_form` para 4 milhões de observações custaria tempo e memória; a coerência vem de `derive_name_fields` | +| Partículas só depois de espaço e fora da primeira palavra | Medido no INE: 1.449 `De`, 432 `La`, 121 `Del`; `Jia Le` mostra que artigo isolado também é nome | + +## Foco de revisão + +1. **Junções erradas por romanização ou acento ambíguos** (`Chen`, `Ines`) e por `other_gender` em prenomes (Task 6: `test_romanizacao_ambigua_nao_junta`, `test_sem_acento_ambiguo_nao_junta`, `test_other_gender_so_em_sobrenomes`). +2. **`different_from` precisa separar** mesmo quando outra ligação aproxima os nomes (Task 6: `test_different_from_separa`). +3. **Ordem das junções** não pode depender da ordem das ligações no arquivo (Task 6: `test_resultado_nao_depende_da_ordem`). +4. **Forma de exibição** sem soma de tipos e com caixa publicada primeiro (Task 5). +5. **Memória** do `run` com o lote real e com o sintético mais o Wikidata (Fechamento). + +## Arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `src/namescommons/normalize.py`, `tests/test_normalize.py` | `detect_script` (R1) | 1 | +| `src/namescommons/normalize.py`, `src/namescommons/schema.py`, `tests/test_normalize.py`, `tests/test_schema.py` | `ascii_form` (R2) e validação de `ascii` (R2.5) | 2 | +| `src/namescommons/normalize.py`, `tests/test_normalize.py`, `tests/test_ci.py` | Partículas (R3) | 3 | +| `src/namescommons/sources.py`, `sources.yaml`, `tests/test_sources_extensoes.py`, `docs/specs/002-pipeline/spec.md` | `strips_accents` (R6, R7.3) | 4 | +| `src/namescommons/aggregate.py`, `tests/test_aggregate.py` | Forma de exibição (R4) | 5 | +| `src/namescommons/variants.py`, `tests/test_variants.py` | `group_ids` (R5) | 6 | +| `src/namescommons/aggregate.py`, `src/namescommons/cli.py`, `tests/test_cli_links.py` | `group_id` no índice, manifesto com as fontes das ligações (R5, R7.4) | 7 | +| `src/namescommons/schema.py`, `tests/test_manifest.py`, `.github/workflows/ci.yml`, `tests/test_ci.py`, `docs/specs/001-esquema/spec.md`, `docs/specs/002-pipeline/spec.md` | Versão (R8), notas de contrato (R7.1, R7.2) e CI (R10) | 8 | + +## Cobertura da spec + +| Requisito | Tarefa | +|---|---| +| R1 | 1 | +| R2 | 2 | +| R3 | 3 | +| R4 | 5 | +| R5 | 6, 7 | +| R6 | 4 | +| R7.1, R7.2 | 8 | +| R7.3 | 4 | +| R7.4 | 7 | +| R8 | 8 | +| R9 | Fechamento | +| R10 | 1 a 8 | +| Critério de aceite 1 | 1 | +| Critério 2 | 2 | +| Critério 3 | 3 | +| Critério 4 | 5 | +| Critério 5 | 6 | +| Critério 6 | 7, 8 | +| Critério 7 | 4 | +| Critérios 8, 9 | Fechamento | diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md new file mode 100644 index 0000000..1c07e26 --- /dev/null +++ b/docs/specs/005-variantes/tasks.md @@ -0,0 +1,78 @@ +# 005. Variantes: tarefas + +Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na raiz do repositório. Antes da Task 1, rodar `uv sync` e conferir que `uv run pytest -q` passa. Cada tarefa começa pelo teste que falha e termina com `uv run pytest -q` verde e um commit. + +--- + +### Task 1: escrita (R1) + +**Arquivos:** `src/namescommons/normalize.py`, `tests/test_normalize.py`. + +- [ ] Testes que falham: `佐々木` é `Hani`; `ユーリ` é `Kana`; `Anna` é `Latn`; `さくら子` e `ゆかりユカリ` são `Jpan`; `김金` é `Kore`; `ଦାସ` é `Orya`, `ܡܝܟܐܝܠ` é `Syrc`, `ᏣᎳᎩ` é `Cher`; letra de escrita fora da tabela continua `Zzzz`; empate entre duas escritas continua resolvido pela ordem do código. +- [ ] `detect_script`: nome Unicode sem os prefixos `FULLWIDTH` e `HALFWIDTH`; prefixos de duas palavras (`OL CHIKI`, `CANADIAN SYLLABICS`, `WARANG CITI`, `MEETEI MAYEK`, `SYLOTI NAGRI`, `EGYPTIAN HIEROGLYPH`); `々` como `Hani`; `ー`, `ゝ`, `ゞ`, `ヽ`, `ヾ` ignorados; `Jpan` e `Kore` pelas combinações de R1.3. +- [ ] `uv run pytest -q` e commit. + +### Task 2: forma ASCII (R2) + +**Arquivos:** `src/namescommons/normalize.py`, `src/namescommons/schema.py`, `tests/test_normalize.py`, `tests/test_schema.py`. + +- [ ] Testes que falham: os exemplos do critério de aceite 2; `ß`, `ø`, `æ`, `ł`, `þ` como antes; `Llorenç` vira `llorenc`; `Nel·lo` vira `nello`; `Jo–Anne` vira `jo-anne`; ZWJ omitido; `Μαρία`, `李`, `ଦାସ` e `Aʃa` vazios; `validate` aceita nome latino com `ascii` vazio e recusa `ascii` com maiúscula ou caractere fora do ASCII. +- [ ] `ascii_form(name, script)`: `script` deixa de decidir (mantido na assinatura por compatibilidade); transliteração caractere a caractere pelas tabelas do ADR 0012, vazio se algum caractere ficar sem transliteração. +- [ ] `uv run pytest -q` e commit. + +### Task 3: capitalização com partículas (R3) + +**Arquivos:** `src/namescommons/normalize.py`, `tests/test_normalize.py`, `tests/test_ci.py`. + +- [ ] Testes que falham: os exemplos do critério de aceite 3; `ALVES DA SILVA` vira `Alves da Silva`; `GABRIEL Y GALAN` vira `Gabriel y Galan`; `JEAN-DE-DIEU` continua `Jean-De-Dieu`; fonte de caixa mista não muda `Da Silva`. +- [ ] `normalize_name` com `uniform_case` verdadeiro aplica R3.1 a R3.3 depois da capitalização por palavra. +- [ ] Atualizar `ES_CSV` em `tests/test_ci.py` (`Francesc d'Assis`, `Joana d'Arc`). +- [ ] `uv run pytest -q` e commit. + +### Task 4: fontes que removem acentos (R6, R7.3) + +**Arquivos:** `src/namescommons/sources.py`, `sources.yaml`, `tests/test_sources_extensoes.py`, `docs/specs/002-pipeline/spec.md`. + +- [ ] Testes que falham: `strips_accents` padrão `false`; `true` aceito; valor não booleano recusado; em `sources.yaml`, exatamente `ca-statcan-births`, `es-ine-residentes` e `us-ssa-births` têm `true`. +- [ ] `OPCIONAIS` e `Source` ganham `strips_accents`; nota em R2 da spec 002. +- [ ] `uv run pytest -q` e commit. + +### Task 5: forma de exibição (R4) + +**Arquivos:** `src/namescommons/aggregate.py`, `tests/test_aggregate.py`. + +- [ ] Testes que falham: a forma com mais nascimentos somados num país e a com mais pessoas vivas em outro não somam contagens no índice (o critério é o número de países); por país, só as observações contadas pesam (fonte não `primary` e edição antiga não pesam); forma publicada vence a gerada por caixa uniforme, no país e no índice; sem fontes (`sources=None`), comportamento por contagens dentro do país continua. +- [ ] `display_name` recebe as observações candidatas, os pesos contados e devolve a forma; `by_country` e `name_index` aplicam R4.1 a R4.3; `CountryRow.published_case` interno. +- [ ] `uv run pytest -q` e commit. + +### Task 6: agrupamento (R5) + +**Arquivos:** `src/namescommons/variants.py` (novo), `tests/test_variants.py` (novo). + +**Produz:** `group_ids(keys: list[str], ascii: list[str], unaccented: list[bool], links: Iterable[VariantLink] | None, kind: str) -> list[str]`, com `keys` ordenadas e sem repetição. + +- [ ] Testes que falham, com ligações escritas à mão: `test_nome_sozinho_e_seu_grupo`; `test_same_item_junta_e_e_nao_dirigido`; `test_other_gender_so_em_sobrenomes`; `test_transliteracao_inequivoca_junta`; `test_romanizacao_ambigua_nao_junta`; `test_sem_acento_junta_marie_eve`; `test_sem_acento_ambiguo_nao_junta`; `test_acentuados_nao_se_juntam_sem_fonte_sem_acento`; `test_different_from_separa`; `test_tipos_diferentes_nunca_juntam`; `test_p460_nao_junta`; `test_ligacao_sem_observacao_e_ignorada`; `test_juncao_pela_chave_e_nao_pelo_texto`; `test_resultado_nao_depende_da_ordem`; `test_group_id_e_a_menor_chave`. +- [ ] Implementação e commit. + +### Task 7: `group_id` no índice e manifesto (R5, R7.4) + +**Arquivos:** `src/namescommons/aggregate.py`, `src/namescommons/cli.py`, `tests/test_cli_links.py`, `tests/test_aggregate.py`. + +- [ ] Testes que falham: `name_index` com ligações e fontes preenche `group_id` em todas as linhas; `build --links` com uma ligação `same_item` grava o mesmo `group_id` nas duas linhas de `given_names.csv`; o `manifest.json` lista a fonte de uma ligação mesmo sem observação dela. +- [ ] `name_index` monta `keys`, `ascii` e a marca de sem acento e chama `group_ids`; `_build` passa fontes e ligações e usa a união das fontes. +- [ ] `uv run pytest -q` e commit. + +### Task 8: versão, notas de contrato e CI (R7.1, R7.2, R8, R10) + +**Arquivos:** `src/namescommons/schema.py`, `tests/test_manifest.py`, `tests/test_io.py`, `.github/workflows/ci.yml`, `tests/test_ci.py`, `docs/specs/001-esquema/spec.md`, `docs/specs/002-pipeline/spec.md`. + +- [ ] Testes que falham: `schema_version` `1.2.0`; o passo da CI das fontes oficiais confere `group_id` em todas as linhas de `given_names.csv`. +- [ ] Notas na spec 001 (R3 e R5) e na spec 002 (R1). +- [ ] `uv run pytest -q` e commit. + +### Fechamento + +- [ ] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. +- [ ] Números reais do agrupamento (R5.8) no pull request. +- [ ] Issues para o que ficou fora: transliteração de grego, hangul, kana e demais escritas; `group_id` em `by_country`; cognatos pela P460. +- [ ] Pull request com `Closes #4`. From 2507b00d9b1ddd15b6f44eeb73871ce8407d333f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:46:45 -0300 Subject: [PATCH 03/18] =?UTF-8?q?Corrige=20a=20detec=C3=A7=C3=A3o=20de=20e?= =?UTF-8?q?scrita:=20largura=20total,=20=E3=80=85,=20=E3=83=BC,=20Jpan,=20?= =?UTF-8?q?Kore=20e=20escritas=20novas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R1 (issue #4). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/tasks.md | 6 ++--- src/namescommons/normalize.py | 39 +++++++++++++++++++++++++++---- tests/test_normalize.py | 32 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 1c07e26..06d329c 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -8,9 +8,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/normalize.py`, `tests/test_normalize.py`. -- [ ] Testes que falham: `佐々木` é `Hani`; `ユーリ` é `Kana`; `Anna` é `Latn`; `さくら子` e `ゆかりユカリ` são `Jpan`; `김金` é `Kore`; `ଦାସ` é `Orya`, `ܡܝܟܐܝܠ` é `Syrc`, `ᏣᎳᎩ` é `Cher`; letra de escrita fora da tabela continua `Zzzz`; empate entre duas escritas continua resolvido pela ordem do código. -- [ ] `detect_script`: nome Unicode sem os prefixos `FULLWIDTH` e `HALFWIDTH`; prefixos de duas palavras (`OL CHIKI`, `CANADIAN SYLLABICS`, `WARANG CITI`, `MEETEI MAYEK`, `SYLOTI NAGRI`, `EGYPTIAN HIEROGLYPH`); `々` como `Hani`; `ー`, `ゝ`, `ゞ`, `ヽ`, `ヾ` ignorados; `Jpan` e `Kore` pelas combinações de R1.3. -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: `佐々木` é `Hani`; `ユーリ` é `Kana`; `Anna` é `Latn`; `さくら子` e `ゆかりユカリ` são `Jpan`; `김金` é `Kore`; `ଦାସ` é `Orya`, `ܡܝܟܐܝܠ` é `Syrc`, `ᏣᎳᎩ` é `Cher`; letra de escrita fora da tabela continua `Zzzz`; empate entre duas escritas continua resolvido pela ordem do código. +- [x] `detect_script`: nome Unicode sem os prefixos `FULLWIDTH` e `HALFWIDTH`; prefixos de duas palavras (`OL CHIKI`, `CANADIAN SYLLABICS`, `WARANG CITI`, `MEETEI MAYEK`, `SYLOTI NAGRI`, `EGYPTIAN HIEROGLYPH`); `々` como `Hani`; `ー`, `ゝ`, `ゞ`, `ヽ`, `ヾ` ignorados; `Jpan` e `Kore` pelas combinações de R1.3. +- [x] `uv run pytest -q` e commit. ### Task 2: forma ASCII (R2) diff --git a/src/namescommons/normalize.py b/src/namescommons/normalize.py index af5aa80..1a2fafe 100644 --- a/src/namescommons/normalize.py +++ b/src/namescommons/normalize.py @@ -12,7 +12,21 @@ "GURMUKHI": "Guru", "SINHALA": "Sinh", "THAI": "Thai", "LAO": "Laoo", "KHMER": "Khmr", "MYANMAR": "Mymr", "TIBETAN": "Tibt", "GEORGIAN": "Geor", "ARMENIAN": "Armn", "ETHIOPIC": "Ethi", "MONGOLIAN": "Mong", + # Spec 005, R1.4: escritas medidas como Zzzz nos dados reais. + "ORIYA": "Orya", "BATAK": "Batk", "BRAHMI": "Brah", "PHOENICIAN": "Phnx", + "SYRIAC": "Syrc", "TIFINAGH": "Tfng", "GOTHIC": "Goth", "CHEROKEE": "Cher", + "MANDAIC": "Mand", "COPTIC": "Copt", "RUNIC": "Runr", "THAANA": "Thaa", + "NKO": "Nkoo", "VAI": "Vaii", } +# Escritas cujo nome Unicode tem duas palavras. +_ESCRITAS_DUPLAS = { + "OL CHIKI": "Olck", "CANADIAN SYLLABICS": "Cans", "WARANG CITI": "Wara", + "MEETEI MAYEK": "Mtei", "SYLOTI NAGRI": "Sylo", "EGYPTIAN HIEROGLYPH": "Egyp", +} +# R1.2: 々 é han; as marcas de som longo e de iteração do kana herdam a escrita vizinha. +_HAN_EXTRA = frozenset("々〆") +_SEM_ESCRITA = frozenset("ーゝゞヽヾ") +_JAPONES = frozenset({"Hani", "Hira", "Kana"}) # Separadores que iniciam uma nova palavra na capitalização. _SEPARADORES = re.compile(r"([ \-'’])") @@ -45,17 +59,34 @@ def name_key(name: str) -> str: return unicodedata.normalize("NFC", name.casefold()) +def _escrita_da_letra(c: str) -> str: + if c in _HAN_EXTRA: + return "Hani" + palavras = unicodedata.name(c, "").split(" ") + if palavras[0] in ("FULLWIDTH", "HALFWIDTH"): + palavras = palavras[1:] + dupla = _ESCRITAS_DUPLAS.get(" ".join(palavras[:2])) + return dupla or _ESCRITAS.get(palavras[0] if palavras else "", "Zzzz") + + def detect_script(name: str) -> str: - """Código ISO 15924 predominante entre as letras; Zyyy sem letras, Zzzz se desconhecido.""" + """Código ISO 15924 predominante entre as letras; Zyyy sem letras, Zzzz se desconhecido. + + Spec 005, R1: Jpan para han com kana, Kore para hangul com han. + """ contagem: dict[str, int] = {} for c in name: - if not c.isalpha(): + if not c.isalpha() or c in _SEM_ESCRITA: continue - primeira = unicodedata.name(c, "").split(" ")[0] - codigo = _ESCRITAS.get(primeira, "Zzzz") + codigo = _escrita_da_letra(c) contagem[codigo] = contagem.get(codigo, 0) + 1 if not contagem: return "Zyyy" + escritas = set(contagem) + if len(escritas) > 1 and escritas <= _JAPONES and escritas & {"Hira", "Kana"}: + return "Jpan" + if escritas == {"Hang", "Hani"}: + return "Kore" return max(sorted(contagem), key=lambda codigo: contagem[codigo]) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index fc4e437..cbfec9e 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -63,3 +63,35 @@ def test_forma_ascii_vazia_fora_da_escrita_latina(): def test_georgiano_nao_recebe_capital_errada(): assert normalize_name("ნინო") == "ნინო" + + +# Spec 005, R1 (issue #4): escrita. +import pytest + + +@pytest.mark.parametrize("nome, escrita", [ + ("佐々木", "Hani"), # 々 conta como han (R1.2) + ("ユーリ", "Kana"), # ー não conta (R1.2) + ("Anna", "Latn"), # latim de largura total (R1.1) + ("アイ", "Kana"), # katakana de meia largura (R1.1) + ("さくら子", "Jpan"), # kanji e hiragana (R1.3) + ("ゆかりユカリ", "Jpan"), # hiragana e katakana (R1.3) + ("ゆかり", "Hira"), + ("김金", "Kore"), # hangul e hanja (R1.3) + ("ଦାସ", "Orya"), # R1.4 + ("ܡܝܟܐܝܠ", "Syrc"), + ("ᏣᎳᎩ", "Cher"), + ("ᱥᱟᱱᱛᱟᱲ", "Olck"), # prefixo de duas palavras + ("ꯃꯤꯇꯩ", "Mtei"), +]) +def test_escrita_r1(nome, escrita): + assert detect_script(nome) == escrita + + +def test_escrita_fora_da_tabela_continua_zzzz(): + assert detect_script("\U00010300\U00010301") == "Zzzz" # itálico antigo (OLD ITALIC) + + +def test_empate_de_escrita_resolvido_pela_ordem_do_codigo(): + assert detect_script("Aб") == "Cyrl" + assert detect_script("ーー") == "Zyyy" From 6b701d50a82046f99d741df788ff828da916d28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:49:44 -0300 Subject: [PATCH 04/18] =?UTF-8?q?Translitera=20para=20ASCII=20pelo=20ICAO?= =?UTF-8?q?=20Doc=209303:=20latim=20com=20NFKD=20e=20cir=C3=ADlico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R2 (issue #4; ADR 0012). Apóstrofos e hífens tipográficos normalizados; ascii vazio quando algum caractere não tem transliteração; validate passa a exigir só ASCII sem maiúsculas. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/spec.md | 6 ++-- docs/specs/005-variantes/tasks.md | 6 ++-- src/namescommons/normalize.py | 58 +++++++++++++++++++++++++------ src/namescommons/schema.py | 4 +-- tests/test_aggregate.py | 3 +- tests/test_normalize.py | 50 ++++++++++++++++++++++++-- tests/test_schema.py | 8 ++++- 7 files changed, 112 insertions(+), 23 deletions(-) diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index 68aa140..1ca75fa 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -27,8 +27,8 @@ As fontes oficiais da Statistics Canada, do INE e da SSA publicam os nomes sem a - **R2.1 Latim.** Decomposição NFKD (ligaduras e dígrafos de compatibilidade como `Dž`, `fi` e letras de largura total viram letras simples), remoção das marcas combinantes e a tabela de letras sem decomposição: `ß` `ss`, `ẞ` `ss`, `ø` `o`, `æ` `ae`, `œ` `oe`, `đ` `d`, `ð` `d`, `ł` `l`, `þ` `th`, `ı` `i`, `ħ` `h`, `ŧ` `t`, `ŋ` `n`, `ə` `e`, `ɛ` `e`, `ɔ` `o`, `ɣ` `g`, `ʙ` `b`. Os valores seguem a tabela A da seção 6 do ICAO Doc 9303 (parte 3, 8.ª edição, 2021) onde ela tem a letra, com a forma de uma letra só onde ela dá alternativa (`Ä` "AE or A" vira `a`), e as letras de alfabetos africanos ausentes do ICAO (`ə`, `ɛ`, `ɔ`, `ɣ`) usam a vogal ou consoante latina de mesma base. - **R2.2 Cirílico.** A tabela B da mesma seção do ICAO Doc 9303, na forma padrão de cada letra (sem as exceções por língua, porque o dataset não sabe a língua do nome): `ж` `zh`, `х` `kh`, `ц` `ts`, `ч` `ch`, `ш` `sh`, `щ` `shch`, `ъ` `ie`, `ы` `y`, `э` `e`, `ю` `iu`, `я` `ia`, `ё` `e`, `й` `i`, `є` `ie`, `ї` `i`, `ђ` `d`, `ѕ` `dz`, `ј` `j`, `љ` `lj`, `њ` `nj`, `ќ` `k`, `ў` `u`, `џ` `dz`, `ґ` `g`, `ғ` `g`, `һ` `c`, `ѫ` `u`, `ѵ` `y` e as letras restantes do russo como na tabela. `ь` não tem transliteração e é omitido. `ћ`, ausente da tabela, vira `c`, como `ć` do alfabeto latino sérvio que lhe corresponde. -- **R2.3 Pontuação do nome.** Espaço, hífen e apóstrofo reto ficam. Apóstrofos tipográficos e modificadores (`’`, `‘`, `ʼ`, `ʻ`, `ʹ`, `ʾ`, `ʿ`, `` ` ``, `´`) viram `'`. Hífens e travessões Unicode (`‐`, `‑`, `‒`, `–`) viram `-`. O ponto médio (`·`, do catalão `l·l`) e caracteres de formatação (categoria `Cf`, como o ZWJ) são omitidos. -- **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias por enquanto (issue de acompanhamento). +- **R2.3 Pontuação do nome.** Caracteres ASCII ficam como estão (em minúsculas), exceto a crase, que vira `'`. Apóstrofos tipográficos e modificadores (`’`, `‘`, `ʼ`, `ʻ`, `ʹ`, `ʾ`, `ʿ`, `` ` ``, `´`) viram `'`. Hífens e travessões Unicode (`‐`, `‑`, `‒`, `–`) viram `-`. O ponto médio (`·`, do catalão `l·l`) e caracteres de formatação (categoria `Cf`, como o ZWJ) são omitidos. +- **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias por enquanto (issue de acompanhamento). Medido no lote real: 8 nomes latinos ficam vazios (letra grega ou tailandesa no meio do nome, `¶`), e 332 nomes cirílicos também, por letras do cazaque, tártaro e tadjique fora da tabela do ICAO (`ә`, `қ`, `ү`, `ҳ`). - **R2.5 Validação.** `validate` deixa de exigir `ascii` não vazio em escrita latina e passa a exigir que `ascii` só tenha caracteres ASCII sem maiúsculas. ### R3. Capitalização das fontes de caixa uniforme @@ -102,7 +102,7 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ## Critérios de aceite 1. `detect_script` devolve `Hani` para `佐々木`, `Kana` para `ユーリ`, `Latn` para `Anna`, `Jpan` para `さくら子`, `Kore` para `김金` e `Orya` para `ଦାସ`. -2. `ascii_form` devolve `dzenana` para `DŽenana`, `o'brien` para `O’Brien`, `aliyev` para `Əliyev`, `ngozi` para `Ŋgozi`, `ivanov` para `Иванов`, `mariia` para `Мария`, `dokovic` para `Ђоковић`, `pelleja` para `Pel·leja` e vazio para `Μαρία` e para um nome latino com letra fora da tabela. +2. `ascii_form` devolve `dzenana` para `DŽenana`, `o'brien` para `O’Brien`, `eliyev` para `Əliyev`, `ngozi` para `Ŋgozi`, `ivanov` para `Иванов`, `mariia` para `Мария`, `dokovic` para `Ђоковић`, `pelleja` para `Pel·leja` e vazio para `Μαρία` e para um nome latino com letra fora da tabela. 3. Com `uniform_case: true`, `JOANA D'ARC` vira `Joana d'Arc`, `MARIA DEL CARMEN` vira `Maria del Carmen`, `VAN DER BERG` vira `Van der Berg`, `DE LA DE LAS HERAS` vira `De la de las Heras`, `JIA LE` vira `Jia Le` e `D'ARCY` vira `D'Arcy`. 4. Uma tabela de teste em que a forma `A` tem mais nascimentos somados de vários anos num país e a forma `a'` tem mais pessoas vivas em outro país mostra que a forma de exibição não soma `births` com `population`, e que a forma publicada vence a capitalização gerada. 5. Uma tabela de teste com ligações escritas à mão produz os grupos esperados: `Marie-Eve` (CA) com `Marie-Ève`; `Иванов`, `Иванова` e `Ivanov` (transliteração inequívoca) num grupo; `Paulo` e `Paula` separados; `Chen` sem grupo com `陈` e `沈`; `Ines` sem grupo; um par `different_from` separado mesmo ligado por `same_item` a um terceiro nome; nada junta prenome com sobrenome; P460 não junta. diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 06d329c..42d233f 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -16,9 +16,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/normalize.py`, `src/namescommons/schema.py`, `tests/test_normalize.py`, `tests/test_schema.py`. -- [ ] Testes que falham: os exemplos do critério de aceite 2; `ß`, `ø`, `æ`, `ł`, `þ` como antes; `Llorenç` vira `llorenc`; `Nel·lo` vira `nello`; `Jo–Anne` vira `jo-anne`; ZWJ omitido; `Μαρία`, `李`, `ଦାସ` e `Aʃa` vazios; `validate` aceita nome latino com `ascii` vazio e recusa `ascii` com maiúscula ou caractere fora do ASCII. -- [ ] `ascii_form(name, script)`: `script` deixa de decidir (mantido na assinatura por compatibilidade); transliteração caractere a caractere pelas tabelas do ADR 0012, vazio se algum caractere ficar sem transliteração. -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: os exemplos do critério de aceite 2; `ß`, `ø`, `æ`, `ł`, `þ` como antes; `Llorenç` vira `llorenc`; `Nel·lo` vira `nello`; `Jo–Anne` vira `jo-anne`; ZWJ omitido; `Μαρία`, `李`, `ଦାସ` e `Aʃa` vazios; `validate` aceita nome latino com `ascii` vazio e recusa `ascii` com maiúscula ou caractere fora do ASCII. +- [x] `ascii_form(name, script)`: `script` deixa de decidir (mantido na assinatura por compatibilidade); transliteração caractere a caractere pelas tabelas do ADR 0012, vazio se algum caractere ficar sem transliteração. +- [x] `uv run pytest -q` e commit. ### Task 3: capitalização com partículas (R3) diff --git a/src/namescommons/normalize.py b/src/namescommons/normalize.py index 1a2fafe..5779d1c 100644 --- a/src/namescommons/normalize.py +++ b/src/namescommons/normalize.py @@ -31,11 +31,33 @@ # Separadores que iniciam uma nova palavra na capitalização. _SEPARADORES = re.compile(r"([ \-'’])") -# Letras latinas que não se decompõem em base + diacrítico. -_LATINAS_ESPECIAIS = str.maketrans({ - "ß": "ss", "ø": "o", "æ": "ae", "œ": "oe", "đ": "d", "ð": "d", - "ł": "l", "þ": "th", "ı": "i", "ħ": "h", -}) +# Spec 005, R2 (ADR 0012): transliteração para ASCII pelo ICAO Doc 9303, parte 3, seção 6 +# (8.ª edição, 2021). Tabelas em minúsculas; o nome passa por casefold antes. +# Latim (tabela A): letras sem decomposição NFKD. Onde o ICAO dá alternativa ("AE or A"), +# fica a letra só; ə, ɛ, ɔ, ɣ e ʙ, ausentes do ICAO, usam a letra latina de mesma base. +_LATIM = { + "ß": "ss", "ø": "o", "æ": "ae", "œ": "oe", "đ": "d", "ð": "d", "ł": "l", + "þ": "th", "ı": "i", "ħ": "h", "ŧ": "t", "ŋ": "n", "ij": "ij", + "ə": "e", "ɛ": "e", "ɔ": "o", "ɣ": "g", "ʙ": "b", +} +# Cirílico (tabela B), na forma padrão de cada letra, sem as exceções por língua. +# ь não tem transliteração no ICAO e é omitido; ћ, ausente da tabela, segue o ć sérvio. +_CIRILICO = { + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", + "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", + "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "kh", "ц": "ts", + "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "ie", "ы": "y", "ь": "", "э": "e", "ю": "iu", + "я": "ia", "ђ": "d", "є": "ie", "ѕ": "dz", "і": "i", "ї": "i", "ј": "j", "љ": "lj", + "њ": "nj", "ќ": "k", "ў": "u", "џ": "dz", "ѫ": "u", "ѵ": "y", "ґ": "g", "ғ": "g", + "һ": "c", "ћ": "c", +} +# R2.3: pontuação do nome. +_APOSTROFOS = frozenset("'’‘ʼʻʹʾʿ`´′") +_HIFENS = frozenset("-‐‑‒–") +_OMITIDOS = frozenset("·") +_TRANSLITERACAO = {**_LATIM, **_CIRILICO} +# Aplicada antes da NFKD, que decomporia ´ em espaço e acento. +_PONTUACAO = str.maketrans({**{c: "'" for c in _APOSTROFOS}, **{c: "-" for c in _HIFENS}}) def normalize_name(raw: str, uniform_case: bool | None = None) -> str: @@ -90,10 +112,24 @@ def detect_script(name: str) -> str: return max(sorted(contagem), key=lambda codigo: contagem[codigo]) -def ascii_form(name: str, script: str) -> str: - """Forma ASCII em minúsculas para escrita latina; vazia para as demais (spec 005 define o resto).""" - if script != "Latn": +def _ascii_do_caractere(c: str) -> str | None: + """Transliteração de um caractere já em casefold e NFKD; None se não houver (R2.4).""" + if c.isascii(): + return c + if c in _OMITIDOS or unicodedata.category(c) in ("Mn", "Mc", "Me", "Cf"): return "" - base = name.casefold().translate(_LATINAS_ESPECIAIS) - base = unicodedata.normalize("NFD", base) - return "".join(c for c in base if not unicodedata.combining(c) and c.isascii()) + return _TRANSLITERACAO.get(c) + + +def ascii_form(name: str, script: str | None = None) -> str: + """Forma ASCII em minúsculas (spec 005, R2; ADR 0012); vazia se algum caractere não tem transliteração. + + `script` não decide mais nada; fica na assinatura por compatibilidade. + """ + partes = [] + for c in unicodedata.normalize("NFKD", name.casefold().translate(_PONTUACAO)): + t = _ascii_do_caractere(c) + if t is None: + return "" + partes.append(t) + return "".join(partes) diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index 6cee494..b36b801 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -86,8 +86,8 @@ def validate(observations: list[Observation], sources: dict[str, Source]) -> Non erros.append(f"{r}: 'name_key' {o.name_key!r} diverge de name_key(name) (R1)") if o.script != detect_script(o.name): erros.append(f"{r}: 'script' {o.script!r} diverge da escrita detectada em name (R1)") - if o.script == "Latn" and not o.ascii: - erros.append(f"{r}: 'ascii' vazio em escrita latina") + if o.ascii and (not o.ascii.isascii() or o.ascii != o.ascii.lower()): + erros.append(f"{r}: 'ascii' {o.ascii!r} com maiúscula ou caractere fora do ASCII (spec 005, R2.5)") if o.kind and o.kind not in KINDS: erros.append(f"{r}: kind '{o.kind}' inválido") fonte = sources.get(o.source_id) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index ad438e4..91db87c 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -117,7 +117,8 @@ def test_indice_given(observacoes, fontes): def test_indice_nome_so_atestado(observacoes, fontes): maria_cirilico = name_index(observacoes, by_country(observacoes, fontes), "given")[4] - assert (maria_cirilico.name, maria_cirilico.script, maria_cirilico.ascii) == ("Мария", "Cyrl", "") + # Spec 005, R2.2: cirílico transliterado pelo ICAO Doc 9303. + assert (maria_cirilico.name, maria_cirilico.script, maria_cirilico.ascii) == ("Мария", "Cyrl", "mariia") assert (maria_cirilico.country_count, maria_cirilico.countries) == (0, ()) assert (maria_cirilico.female_share, maria_cirilico.gender_basis) == (None, 0) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index cbfec9e..20e0ab6 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -56,9 +56,9 @@ def test_forma_ascii_latina(): assert ascii_form("Maria Da Silva", "Latn") == "maria da silva" -def test_forma_ascii_vazia_fora_da_escrita_latina(): - assert ascii_form("Мария", "Cyrl") == "" +def test_forma_ascii_vazia_fora_das_escritas_transliteradas(): assert ascii_form("李", "Hani") == "" + assert ascii_form("Μαρία", "Grek") == "" def test_georgiano_nao_recebe_capital_errada(): @@ -95,3 +95,49 @@ def test_escrita_fora_da_tabela_continua_zzzz(): def test_empate_de_escrita_resolvido_pela_ordem_do_codigo(): assert detect_script("Aб") == "Cyrl" assert detect_script("ーー") == "Zyyy" + + +# Spec 005, R2 (issue #4; ADR 0012): forma ASCII. +@pytest.mark.parametrize("nome, esperado", [ + ("DŽenana", "dzenana"), # dígrafo de compatibilidade (NFKD) + ("fiona", "fiona"), # ligadura + ("Anna", "anna"), # largura total + ("O’Brien", "o'brien"), # apóstrofo curvo vira reto + ("Hawaiʻi", "hawai'i"), # ʻokina + ("Jo–Anne", "jo-anne"), # meia-risca vira hífen + ("Əliyev", "eliyev"), + ("Ŋgozi", "ngozi"), + ("Kɔfi", "kofi"), + ("Ɛsi", "esi"), + ("Llorenç", "llorenc"), + ("Nel·lo", "nello"), + ("Pel·leja", "pelleja"), + ("Strauß", "strauss"), + ("Søren", "soren"), + ("Æsa", "aesa"), + ("Łukasz", "lukasz"), + ("Þóra", "thora"), + ("Ðorđe", "dorde"), + ("İrem", "irem"), + ("Mi\u200dna", "mina"), # ZWJ omitido + ("Иванов", "ivanov"), + ("Мария", "mariia"), + ("Щукин", "shchukin"), + ("Пьер", "per"), # ь omitido + ("Ђоковић", "dokovic"), + ("Їжак", "izhak"), + ("Ґанна", "ganna"), + ("Љиљана", "ljiljana"), +]) +def test_forma_ascii_r2(nome, esperado): + assert ascii_form(nome, detect_script(nome)) == esperado + + +@pytest.mark.parametrize("nome", ["Μαρία", "李", "ଦାସ", "Aʃa", "Maria李", "민준"]) +def test_forma_ascii_vazia_quando_algum_caractere_nao_tem_transliteracao(nome): + assert ascii_form(nome, detect_script(nome)) == "" + + +def test_acento_agudo_isolado_vira_apostrofo(): + assert ascii_form("D´Arcy", "Latn") == "d'arcy" + assert ascii_form("D`Arcy", "Latn") == "d'arcy" diff --git a/tests/test_schema.py b/tests/test_schema.py index 7738004..a098fb3 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -25,7 +25,8 @@ def test_fixture_valida(observacoes, fontes): ({"count_type": "attested"}, "R1.1"), ({"count_type": "deaths"}, "count_type"), ({"source_id": "inexistente"}, "sources.yaml"), - ({"ascii": ""}, "ascii"), + ({"ascii": "Alvaro"}, "ascii"), + ({"ascii": "álvaro"}, "ascii"), ]) def test_observacao_invalida(observacoes, fontes, campos, trecho): ruim = replace(observacoes[0], **campos) @@ -100,3 +101,8 @@ def agora(tz): validate([replace(observacoes[0], year=2031)], fontes) with pytest.raises(ValidationError, match="year 2032 fora de 1800 a 2031"): validate([replace(observacoes[0], year=2032)], fontes) + + +def test_ascii_vazio_em_escrita_latina_e_aceito(observacoes, fontes): + """Spec 005, R2.4 e R2.5: nome latino com letra sem transliteração tem ascii vazio.""" + validate([replace(observacoes[0], ascii="")], fontes) From 427f1a75d0a3a3bc85a7c5464f67b8de4b57d5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:50:44 -0300 Subject: [PATCH 05/18] =?UTF-8?q?P=C3=B5e=20part=C3=ADculas=20em=20min?= =?UTF-8?q?=C3=BAsculas=20na=20capitaliza=C3=A7=C3=A3o=20das=20fontes=20de?= =?UTF-8?q?=20caixa=20uniforme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R3 (issue #4): Maria del Carmen, Van der Berg, Joana d'Arc; artigos só depois de partícula (Jia Le continua). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/tasks.md | 8 ++++---- src/namescommons/normalize.py | 22 +++++++++++++++++++++- tests/test_ci.py | 4 ++-- tests/test_normalize.py | 28 +++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 42d233f..dcc969a 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -24,10 +24,10 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/normalize.py`, `tests/test_normalize.py`, `tests/test_ci.py`. -- [ ] Testes que falham: os exemplos do critério de aceite 3; `ALVES DA SILVA` vira `Alves da Silva`; `GABRIEL Y GALAN` vira `Gabriel y Galan`; `JEAN-DE-DIEU` continua `Jean-De-Dieu`; fonte de caixa mista não muda `Da Silva`. -- [ ] `normalize_name` com `uniform_case` verdadeiro aplica R3.1 a R3.3 depois da capitalização por palavra. -- [ ] Atualizar `ES_CSV` em `tests/test_ci.py` (`Francesc d'Assis`, `Joana d'Arc`). -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: os exemplos do critério de aceite 3; `ALVES DA SILVA` vira `Alves da Silva`; `GABRIEL Y GALAN` vira `Gabriel y Galan`; `JEAN-DE-DIEU` continua `Jean-De-Dieu`; fonte de caixa mista não muda `Da Silva`. +- [x] `normalize_name` com `uniform_case` verdadeiro aplica R3.1 a R3.3 depois da capitalização por palavra. +- [x] Atualizar `ES_CSV` em `tests/test_ci.py` (`Francesc d'Assis`, `Joana d'Arc`). +- [x] `uv run pytest -q` e commit. ### Task 4: fontes que removem acentos (R6, R7.3) diff --git a/src/namescommons/normalize.py b/src/namescommons/normalize.py index 5779d1c..88f1cb7 100644 --- a/src/namescommons/normalize.py +++ b/src/namescommons/normalize.py @@ -31,6 +31,13 @@ # Separadores que iniciam uma nova palavra na capitalização. _SEPARADORES = re.compile(r"([ \-'’])") +# Spec 005, R3: partículas em minúsculas fora da primeira palavra (fontes de caixa uniforme). +_PARTICULAS = frozenset({ + "da", "das", "de", "del", "della", "der", "den", "des", "di", "do", "dos", "du", "van", "von", "y", +}) +_ARTIGOS = frozenset({"la", "las", "le", "los"}) # só logo depois de uma partícula (R3.2) +_ELISOES = ("d'", "l'") # R3.3 + # Spec 005, R2 (ADR 0012): transliteração para ASCII pelo ICAO Doc 9303, parte 3, seção 6 # (8.ª edição, 2021). Tabelas em minúsculas; o nome passa por casefold antes. # Latim (tabela A): letras sem decomposição NFKD. Onde o ICAO dá alternativa ("AE or A"), @@ -72,10 +79,23 @@ def normalize_name(raw: str, uniform_case: bool | None = None) -> str: uniform_case = tem_caixa and nome in (nome.upper(), nome.lower()) if uniform_case: partes = _SEPARADORES.split(nome.lower()) - nome = "".join(p[:1].title() + p[1:] for p in partes) + nome = _particulas("".join(p[:1].title() + p[1:] for p in partes)) return unicodedata.normalize("NFC", nome) +def _particulas(nome: str) -> str: + """Spec 005, R3.1 a R3.3: só palavras separadas por espaço e fora da primeira.""" + palavras = nome.split(" ") + for i in range(1, len(palavras)): + minuscula = palavras[i].lower() + anterior = palavras[i - 1].lower() + if minuscula in _PARTICULAS or (minuscula in _ARTIGOS and anterior in _PARTICULAS): + palavras[i] = minuscula + elif minuscula[:2] in _ELISOES and len(minuscula) > 2: + palavras[i] = minuscula[:2] + palavras[i][2:] + return " ".join(palavras) + + def name_key(name: str) -> str: """Identidade do nome: casefold em NFC, com acentos preservados.""" return unicodedata.normalize("NFC", name.casefold()) diff --git a/tests/test_ci.py b/tests/test_ci.py index 04dcaf9..8666252 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -113,8 +113,8 @@ def test_passo_offline_da_ci_roda_localmente(tmp_path): given,Iñigo,iñigo,inigo,Latn,22083,population,5,0.000000,22083,2025,2025 given,Llorenç,llorenç,llorenc,Latn,2513,population,6,0.000000,2513,2025,2025 given,Françoise,françoise,francoise,Latn,338,population,7,1.000000,338,2025,2025 -given,Francesc D'Assis,francesc d'assis,francesc d'assis,Latn,192,population,8,0.000000,192,2025,2025 -given,Joana D'Arc,joana d'arc,joana d'arc,Latn,21,population,9,1.000000,21,2025,2025 +given,Francesc d'Assis,francesc d'assis,francesc d'assis,Latn,192,population,8,0.000000,192,2025,2025 +given,Joana d'Arc,joana d'arc,joana d'arc,Latn,21,population,9,1.000000,21,2025,2025 given,Zulma Patricia,zulma patricia,zulma patricia,Latn,20,population,10,1.000000,20,2025,2025 given,Zunair,zunair,zunair,Latn,20,population,10,0.000000,20,2025,2025 """ diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 20e0ab6..edc0733 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -2,7 +2,7 @@ def test_caixa_uniforme_vira_capitalizada_por_palavra(): - assert normalize_name(" MARIA DA SILVA ") == "Maria Da Silva" + assert normalize_name(" MARIA DA SILVA ") == "Maria da Silva" # spec 005, R3.1 assert normalize_name("jean-luc") == "Jean-Luc" assert normalize_name("o'neil") == "O'Neil" @@ -141,3 +141,29 @@ def test_forma_ascii_vazia_quando_algum_caractere_nao_tem_transliteracao(nome): def test_acento_agudo_isolado_vira_apostrofo(): assert ascii_form("D´Arcy", "Latn") == "d'arcy" assert ascii_form("D`Arcy", "Latn") == "d'arcy" + + +# Spec 005, R3 (issue #4): partículas na capitalização das fontes de caixa uniforme. +@pytest.mark.parametrize("bruto, esperado", [ + ("JOANA D'ARC", "Joana d'Arc"), + ("FRANCESC D'ASSIS", "Francesc d'Assis"), + ("MARIA DEL CARMEN", "Maria del Carmen"), + ("VAN DER BERG", "Van der Berg"), + ("DE LA DE LAS HERAS", "De la de las Heras"), + ("ABEL DE LA CRUZ", "Abel de la Cruz"), + ("ALVES DA SILVA", "Alves da Silva"), + ("GABRIEL Y GALAN", "Gabriel y Galan"), + ("JIA LE", "Jia Le"), + ("MARIA LA", "Maria La"), + ("D'ARCY", "D'Arcy"), + ("JEAN-DE-DIEU", "Jean-De-Dieu"), + ("MARIE L'AMOUR", "Marie l'Amour"), + ("O'NEIL", "O'Neil"), +]) +def test_particulas_em_caixa_uniforme(bruto, esperado): + assert normalize_name(bruto, uniform_case=True) == esperado + + +def test_caixa_publicada_nao_muda_particulas(): + assert normalize_name("Da Silva", uniform_case=False) == "Da Silva" + assert normalize_name("Joana D'Arc", uniform_case=False) == "Joana D'Arc" From 203ee3ee21cb59a34bc5f43d77650ceac34584f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:51:37 -0300 Subject: [PATCH 06/18] =?UTF-8?q?Acrescenta=20strips=5Faccents=20=C3=A0s?= =?UTF-8?q?=20fontes=20que=20removem=20acentos=20(StatCan,=20INE=20e=20SSA?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R6 e R7.3 (ADR 0011); nota na spec 002, R2. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/002-pipeline/spec.md | 2 ++ docs/specs/005-variantes/tasks.md | 6 +++--- sources.yaml | 5 ++++- src/namescommons/sources.py | 3 ++- tests/test_sources_extensoes.py | 17 ++++++++++++++++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/specs/002-pipeline/spec.md b/docs/specs/002-pipeline/spec.md index 7332555..42e2dad 100644 --- a/docs/specs/002-pipeline/spec.md +++ b/docs/specs/002-pipeline/spec.md @@ -39,6 +39,8 @@ Campos novos, opcionais: - `uniform_case` (booleano, padrão `false`): a fonte publica todos os nomes na mesma caixa e a capitalização por palavra da 001 se aplica a todos. - `adapter` (booleano, padrão `true`): `false` registra uma fonte usada só manualmente. +Nota (spec 005, R6): `strips_accents` (booleano, padrão `false`) marca a fonte que publica os nomes sem acento por regra própria; ele decide a correspondência sem acento do agrupamento de variantes ([ADR 0011](../../adr/0011-correspondencia-sem-acento.md)). + ### R3. Validação cruzada com a fonte `validate` passa a recusar, além do que a 001 recusa: diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index dcc969a..6d8506b 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -33,9 +33,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/sources.py`, `sources.yaml`, `tests/test_sources_extensoes.py`, `docs/specs/002-pipeline/spec.md`. -- [ ] Testes que falham: `strips_accents` padrão `false`; `true` aceito; valor não booleano recusado; em `sources.yaml`, exatamente `ca-statcan-births`, `es-ine-residentes` e `us-ssa-births` têm `true`. -- [ ] `OPCIONAIS` e `Source` ganham `strips_accents`; nota em R2 da spec 002. -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: `strips_accents` padrão `false`; `true` aceito; valor não booleano recusado; em `sources.yaml`, exatamente `ca-statcan-births`, `es-ine-residentes` e `us-ssa-births` têm `true`. +- [x] `OPCIONAIS` e `Source` ganham `strips_accents`; nota em R2 da spec 002. +- [x] `uv run pytest -q` e commit. ### Task 5: forma de exibição (R4) diff --git a/sources.yaml b/sources.yaml index 6ac1e68..15b29df 100644 --- a/sources.yaml +++ b/sources.yaml @@ -1,4 +1,4 @@ -# Fontes do dataset (spec 001, R2; spec 002, R2). Licenças conferidas na spec 003, R1, na spec 003d (ADR 0004), na spec 003b, R2, e na spec 004, R1. +# Fontes do dataset (spec 001, R2; spec 002, R2). Licenças conferidas na spec 003, R1, na spec 003d (ADR 0004), na spec 003b, R2, e na spec 004, R1. Evidência de strips_accents na spec 005, R6. sources: - id: us-ssa-births name: Social Security Administration, Popular Baby Names (national data) @@ -10,6 +10,7 @@ sources: primary: true suppression: 5 retrieved: 2026-10-12 + strips_accents: true - id: fr-insee-births name: INSEE, Fichier des prénoms (France, nível nacional) url: https://www.insee.fr/fr/statistiques/8595130 @@ -62,6 +63,7 @@ sources: suppression: 5 retrieved: 2026-10-12 uniform_case: true + strips_accents: true - id: es-ine-residentes name: INE, Apellidos y nombres más frecuentes (residentes en España, total nacional) url: https://ine.es/dyngs/INEbase/operacion.htm?c=Estadistica_C&cid=1254736177009&menu=resultados&idp=1254735572981 @@ -73,6 +75,7 @@ sources: suppression: 20 retrieved: 2026-10-12 uniform_case: true + strips_accents: true - id: nz-dia-births name: Department of Internal Affairs, Baby Name popularity over time (Nova Zelândia) url: https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time diff --git a/src/namescommons/sources.py b/src/namescommons/sources.py index 7c91704..8c2fc46 100644 --- a/src/namescommons/sources.py +++ b/src/namescommons/sources.py @@ -22,7 +22,7 @@ "count_type", "primary", "suppression", "retrieved", ) # Campos opcionais da spec 002 (R2) e seus valores padrão. -OPCIONAIS = {"uniform_case": False, "adapter": True} +OPCIONAIS = {"uniform_case": False, "adapter": True, "strips_accents": False} # strips_accents: spec 005, R6 class SourceError(ValueError): @@ -43,6 +43,7 @@ class Source: retrieved: date uniform_case: bool = False adapter: bool = True + strips_accents: bool = False def is_country(code: object) -> bool: diff --git a/tests/test_sources_extensoes.py b/tests/test_sources_extensoes.py index 2b2e4c7..2284c6f 100644 --- a/tests/test_sources_extensoes.py +++ b/tests/test_sources_extensoes.py @@ -27,7 +27,7 @@ def test_campos_opcionais_declarados(): assert (f.uniform_case, f.adapter) == (True, False) -@pytest.mark.parametrize("campo", ["uniform_case", "adapter"]) +@pytest.mark.parametrize("campo", ["uniform_case", "adapter", "strips_accents"]) @pytest.mark.parametrize("valor", ["sim", 1, None]) def test_campo_opcional_nao_booleano(campo, valor): with pytest.raises(SourceError, match=f"'{campo}' deve ser true ou false"): @@ -76,3 +76,18 @@ def test_url_http_e_https_aceitas(url): def test_url_malformada_nomeia_a_fonte_em_vez_de_levantar_value_error(): with pytest.raises(SourceError, match="pt-exemplo: 'url' deve usar o esquema http ou https"): parse_sources([fonte(url="http://[::1")]) + + +def test_strips_accents_tem_padrao_false_e_aceita_true(): + """Spec 005, R6.""" + assert parse_sources([fonte()])["pt-exemplo"].strips_accents is False + assert parse_sources([fonte(strips_accents=True)])["pt-exemplo"].strips_accents is True + + +def test_fontes_que_removem_acentos_em_sources_yaml(): + """Spec 005, R6: só as fontes com evidência registrada na spec.""" + from pathlib import Path + fontes = load_sources(Path(__file__).resolve().parent.parent / "sources.yaml") + assert sorted(f.id for f in fontes.values() if f.strips_accents) == [ + "ca-statcan-births", "es-ine-residentes", "us-ssa-births", + ] From d3a292c96ef58e9b95370d0131c3808f0130b1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:52:55 -0300 Subject: [PATCH 07/18] =?UTF-8?q?Escolhe=20a=20forma=20de=20exibi=C3=A7?= =?UTF-8?q?=C3=A3o=20sem=20somar=20tipos,=20edi=C3=A7=C3=B5es=20e=20fontes?= =?UTF-8?q?=20n=C3=A3o=20contadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R4 (issue #4): por país pesa só o que a linha conta; no índice, votos de países; caixa publicada antes da gerada. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/tasks.md | 6 +-- src/namescommons/aggregate.py | 45 +++++++++++++++---- tests/test_aggregate.py | 72 +++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 6d8506b..1c2941f 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -41,9 +41,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/aggregate.py`, `tests/test_aggregate.py`. -- [ ] Testes que falham: a forma com mais nascimentos somados num país e a com mais pessoas vivas em outro não somam contagens no índice (o critério é o número de países); por país, só as observações contadas pesam (fonte não `primary` e edição antiga não pesam); forma publicada vence a gerada por caixa uniforme, no país e no índice; sem fontes (`sources=None`), comportamento por contagens dentro do país continua. -- [ ] `display_name` recebe as observações candidatas, os pesos contados e devolve a forma; `by_country` e `name_index` aplicam R4.1 a R4.3; `CountryRow.published_case` interno. -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: a forma com mais nascimentos somados num país e a com mais pessoas vivas em outro não somam contagens no índice (o critério é o número de países); por país, só as observações contadas pesam (fonte não `primary` e edição antiga não pesam); forma publicada vence a gerada por caixa uniforme, no país e no índice; sem fontes (`sources=None`), comportamento por contagens dentro do país continua. +- [x] `display_name` recebe as observações candidatas, os pesos contados e devolve a forma; `by_country` e `name_index` aplicam R4.1 a R4.3; `CountryRow.published_case` interno. +- [x] `uv run pytest -q` e commit. ### Task 6: agrupamento (R5) diff --git a/src/namescommons/aggregate.py b/src/namescommons/aggregate.py index 7672b71..b65a4d2 100644 --- a/src/namescommons/aggregate.py +++ b/src/namescommons/aggregate.py @@ -31,6 +31,7 @@ class CountryRow: female: int # interno: alimenta o índice mundial; não sai no CSV first_year: int | None last_year: int | None + published_case: bool = True # interno (spec 005, R4.3): a forma exibida vem de fonte de caixa publicada @dataclass(frozen=True) @@ -55,14 +56,27 @@ class IndexRow: sources: tuple[str, ...] -def display_name(obs: list[Observation]) -> str: - """Forma mais frequente por soma de count; empate: mais atestada; depois ordem lexicográfica.""" - peso: dict[str, int] = defaultdict(int) +def display_name(obs: list[Observation], pesos: dict[str, int] | None = None) -> str: + """Forma de maior peso; empate: mais atestada; depois a menor em ordem de código Unicode. + + Sem `pesos`, o peso é a soma de count de cada forma (spec 001, R3.2). A spec 005 (R4) passa + pesos que só contam o que a agregação usa. + """ atestacoes: dict[str, int] = defaultdict(int) for o in obs: - peso[o.name] += o.count or 0 atestacoes[o.name] += 1 - return min(peso, key=lambda n: (-peso[n], -atestacoes[n], n)) + if pesos is None: + pesos = defaultdict(int) + for o in obs: + pesos[o.name] += o.count or 0 + return min(atestacoes, key=lambda n: (-pesos.get(n, 0), -atestacoes[n], n)) + + +def _caixa_publicada(obs: list[Observation], sources: dict[str, Source] | None) -> list[Observation]: + """Spec 005, R4.1: observações de fontes de caixa publicada; vazio se não houver nenhuma.""" + if sources is None: + return obs + return [o for o in obs if not sources[o.source_id].uniform_case] def _fontes_oficiais(observations: list[Observation], sources: dict[str, Source]) -> dict[tuple, str]: @@ -146,7 +160,11 @@ def by_country(observations: list[Observation], sources: dict[str, Source]) -> d tipo, contadas, primeiro, ultimo = _contagem(obs, oficiais, edicoes) f = sum(o.count for o in contadas if o.gender == "F") m = sum(o.count for o in contadas if o.gender == "M") - nome = display_name(obs) + publicadas = _caixa_publicada(obs, sources) + pesos: dict[str, int] = defaultdict(int) + for o in contadas: + pesos[o.name] += o.count + nome = display_name(publicadas or obs, pesos) escrita = detect_script(nome) linhas[pais].append(CountryRow( country=pais, kind=kind, name=nome, name_key=chave, @@ -154,15 +172,26 @@ def by_country(observations: list[Observation], sources: dict[str, Source]) -> d count=sum(o.count for o in contadas) if contadas else None, count_type=tipo, rank=None, female_share=f / (f + m) if f + m else None, gender_basis=f + m, female=f, - first_year=primeiro, last_year=ultimo, + first_year=primeiro, last_year=ultimo, published_case=bool(publicadas), )) return {pais: _ranquear(rows) for pais, rows in sorted(linhas.items())} +def _exibicao_no_indice(obs: list[Observation], rows: list[CountryRow], sources) -> str: + """Spec 005, R4.3: votos de países, sem somar contagens entre eles.""" + publicadas = _caixa_publicada(obs, sources) + votos: dict[str, int] = defaultdict(int) + for r in rows: + if r.published_case or not publicadas: + votos[r.name] += 1 + return display_name(publicadas or obs, votos) + + def name_index( observations: list[Observation], country_rows: dict[str, list[CountryRow]], kind: str, + sources: dict[str, Source] | None = None, ) -> list[IndexRow]: obs_por_chave: dict[str, list[Observation]] = defaultdict(list) for o in observations: @@ -177,7 +206,7 @@ def name_index( for chave in sorted(obs_por_chave): obs = obs_por_chave[chave] rows = sorted(linhas_por_chave.get(chave, []), key=lambda r: r.country) - nome = display_name(obs) + nome = _exibicao_no_indice(obs, rows, sources) escrita = detect_script(nome) female = sum(r.female for r in rows) base = sum(r.gender_basis for r in rows) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index 91db87c..ac6c35a 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -40,10 +40,11 @@ def test_nome_so_na_fonte_secundaria_fica_sem_contagem(observacoes, fontes): ) -def test_forma_de_exibicao_usa_todas_as_fontes(observacoes, fontes): +def test_forma_de_exibicao_so_pesa_as_observacoes_contadas(observacoes, fontes): + """Spec 005, R4.2: a fonte que não é primary não pesa na forma exibida no país.""" deshawn = _linha(by_country(observacoes, fontes)["US"], "given", "deshawn") - assert deshawn.name == "DeShawn" # 40 (us-births-b) > 30 (us-ssa) - assert deshawn.count == 30 # mas a contagem vem só da primary + assert deshawn.name == "Deshawn" # 30 (us-ssa, contada); os 40 de us-births-b não pesam + assert deshawn.count == 30 def test_br_population_usa_a_edicao_mais_recente(observacoes, fontes): @@ -169,3 +170,68 @@ def test_population_nome_so_em_edicao_antiga_nao_e_contado(observacoes, fontes): ) maria = _linha(br, "given", "maria") assert (maria.count, maria.rank) == (12000, 1) + + +# Spec 005, R4 (issue #4): forma de exibição sem somar tipos, edições e fontes não contadas. +def _fontes_r4(**caixa_uniforme): + base = {"license": "CC0-1.0", "attribution": "x", "suppression": None, "retrieved": date(2026, 1, 1)} + return parse_sources([ + {**base, "id": "us", "name": "us", "url": "https://us", "country": "US", "count_type": "births", + "primary": True, "uniform_case": caixa_uniforme.get("us", False)}, + {**base, "id": "es", "name": "es", "url": "https://es", "country": "ES", "count_type": "population", + "primary": True, "uniform_case": caixa_uniforme.get("es", False)}, + {**base, "id": "nz", "name": "nz", "url": "https://nz", "country": "NZ", "count_type": "births", + "primary": True, "uniform_case": caixa_uniforme.get("nz", False)}, + {**base, "id": "wd", "name": "wd", "url": "https://wd", "country": None, "count_type": "attested", + "primary": False}, + ]) + + +def _o(nome, fonte, pais, tipo, ano, contagem, uniforme=False): + return make_observation(nome, kind="family", source_id=fonte, country=pais, count_type=tipo, + year=ano, count=contagem, uniform_case=uniforme) + + +def test_indice_nao_soma_births_com_population(): + """McKenzie tem mais nascimentos somados (US); Mckenzie, mais pessoas vivas (ES) e mais países.""" + fontes = _fontes_r4() + obs = [ + _o("McKenzie", "us", "US", "births", 2000, 4000), + _o("McKenzie", "us", "US", "births", 2001, 4000), + _o("Mckenzie", "us", "US", "births", 2001, 10), + _o("Mckenzie", "es", "ES", "population", 2025, 500), + _o("Mckenzie", "nz", "NZ", "births", 2025, 30), + ] + paises = by_country(obs, fontes) + assert [_linha(paises[p], "family", "mckenzie").name for p in ("ES", "NZ", "US")] == [ + "Mckenzie", "Mckenzie", "McKenzie", + ] + (linha,) = name_index(obs, paises, "family", sources=fontes) + assert linha.name == "Mckenzie" # dois países contra um; a soma 8.000 + 10 + 500 + 30 não é feita + + +def test_por_pais_edicao_antiga_nao_pesa(): + fontes = _fontes_r4() + obs = [ + _o("DeLuca", "es", "ES", "population", 2010, 900), + _o("Deluca", "es", "ES", "population", 2025, 100), + ] + assert _linha(by_country(obs, fontes)["ES"], "family", "deluca").name == "Deluca" + + +def test_forma_publicada_vence_a_capitalizacao_gerada(): + """R4.1: a forma de fonte de caixa uniforme só vale quando não há outra.""" + fontes = _fontes_r4(es=True) + obs = [ + _o("DA SILVA", "es", "ES", "population", 2025, 9000, uniforme=True), # vira "Da Silva" + make_observation("da Silva", kind="family", source_id="wd", count_type="attested", uniform_case=False), + ] + paises = by_country(obs, fontes) + assert _linha(paises["ES"], "family", "da silva").name == "Da Silva" # no país só há a gerada + (linha,) = name_index(obs, paises, "family", sources=fontes) + assert linha.name == "da Silva" + + +def test_indice_sem_fontes_mantem_todas_as_formas_candidatas(observacoes, fontes): + indice = name_index(observacoes, by_country(observacoes, fontes), "given") + assert indice[1].name == "Deshawn" From 41f08452325b0cb45e04c07e89e1537f34ac1c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:54:10 -0300 Subject: [PATCH 08/18] Agrupa variantes em duas fases, com different_from como corte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R5 (ADRs 0010 e 0011): same_item e, em sobrenomes, other_gender; depois transliteração e nome sem acento só quando inequívocos; P460 não junta; group_id é a menor chave. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/spec.md | 2 +- docs/specs/005-variantes/tasks.md | 4 +- src/namescommons/variants.py | 121 +++++++++++++++++++++ tests/test_variants.py | 174 ++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/namescommons/variants.py create mode 100644 tests/test_variants.py diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index 1ca75fa..3f3c9d1 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -59,7 +59,7 @@ Cada linha de `given_names` e `family_names` recebe `group_id`. O agrupamento é - **R5.3 Fase 1, mesmo nome.** Juntam: `same_item` (rótulos nativos do mesmo item) e, só em sobrenomes, `other_gender` (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. - **R5.4 Fase 2, anexação inequívoca.** Calculada sobre os grupos da fase 1: - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1. Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. - - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1. `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. + - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós latinos do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1 (um nó cirílico com o mesmo `ascii`, como `Иван` e `Ivan`, não entra: isso é transliteração, não acento). `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. - **R5.5 Não juntam.** `said_to_be_the_same` (P460) não junta: o fecho transitivo dessas ligações forma um componente de 14.039 nomes mesmo com os cortes de R5.6 (medido nos dados reais, ADR 0010). Nomes que só diferem em acento, sem fonte que remove acentos, também não juntam. - **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções das fases 1 e 2 são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. - **R5.7 Identificador.** `group_id` é o menor `name_key` do grupo em ordem de código Unicode (`marie-eve` para `Marie-Eve` e `Marie-Ève`). Nome sem variante forma um grupo sozinho, e seu `group_id` é o próprio `name_key`. Sem `variant_links` (`build` sem `--links`), só a regra sem acento junta nomes. diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 1c2941f..0c7e00b 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -51,8 +51,8 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Produz:** `group_ids(keys: list[str], ascii: list[str], unaccented: list[bool], links: Iterable[VariantLink] | None, kind: str) -> list[str]`, com `keys` ordenadas e sem repetição. -- [ ] Testes que falham, com ligações escritas à mão: `test_nome_sozinho_e_seu_grupo`; `test_same_item_junta_e_e_nao_dirigido`; `test_other_gender_so_em_sobrenomes`; `test_transliteracao_inequivoca_junta`; `test_romanizacao_ambigua_nao_junta`; `test_sem_acento_junta_marie_eve`; `test_sem_acento_ambiguo_nao_junta`; `test_acentuados_nao_se_juntam_sem_fonte_sem_acento`; `test_different_from_separa`; `test_tipos_diferentes_nunca_juntam`; `test_p460_nao_junta`; `test_ligacao_sem_observacao_e_ignorada`; `test_juncao_pela_chave_e_nao_pelo_texto`; `test_resultado_nao_depende_da_ordem`; `test_group_id_e_a_menor_chave`. -- [ ] Implementação e commit. +- [x] Testes que falham, com ligações escritas à mão: `test_nome_sozinho_e_seu_grupo`; `test_same_item_junta_e_e_nao_dirigido`; `test_other_gender_so_em_sobrenomes`; `test_transliteracao_inequivoca_junta`; `test_romanizacao_ambigua_nao_junta`; `test_sem_acento_junta_marie_eve`; `test_sem_acento_ambiguo_nao_junta`; `test_acentuados_nao_se_juntam_sem_fonte_sem_acento`; `test_different_from_separa`; `test_tipos_diferentes_nunca_juntam`; `test_p460_nao_junta`; `test_ligacao_sem_observacao_e_ignorada`; `test_juncao_pela_chave_e_nao_pelo_texto`; `test_resultado_nao_depende_da_ordem`; `test_group_id_e_a_menor_chave`. +- [x] Implementação e commit. ### Task 7: `group_id` no índice e manifesto (R5, R7.4) diff --git a/src/namescommons/variants.py b/src/namescommons/variants.py new file mode 100644 index 0000000..3c9c16e --- /dev/null +++ b/src/namescommons/variants.py @@ -0,0 +1,121 @@ +"""Agrupamento de variantes (spec 005, R5; ADRs 0010 e 0011): group_id de cada nome do índice. + +Os nós são as chaves (name_key) de um kind com observação, numeradas pela posição na lista +ordenada. As junções são feitas em duas fases sobre inteiros, e `different_from` proíbe que dois +nós fiquem no mesmo grupo. +""" + +from collections import defaultdict +from collections.abc import Iterable + +from namescommons.links import VariantLink + +# R5.3: relações da fase 1 por kind. +FASE_1 = {"given": frozenset({"same_item"}), "family": frozenset({"same_item", "other_gender"})} + + +class _Grupos: + """União e busca com proibições entre nós (R5.6).""" + + def __init__(self, n: int): + self.pai = list(range(n)) + self.proibidos: dict[int, set[int]] = {} # raiz -> nós proibidos para o grupo + + def raiz(self, x: int) -> int: + pai = self.pai + while pai[x] != x: + pai[x] = pai[pai[x]] + x = pai[x] + return x + + def proibir(self, a: int, b: int) -> None: + self.proibidos.setdefault(a, set()).add(b) + self.proibidos.setdefault(b, set()).add(a) + + def juntar(self, a: int, b: int) -> bool: + """Junta os grupos de a e b; False se já estavam juntos ou se a junção é proibida.""" + ra, rb = self.raiz(a), self.raiz(b) + if ra == rb: + return False + pa, pb = self.proibidos.get(ra, ()), self.proibidos.get(rb, ()) + # As proibições são simétricas: basta olhar o lado menor. + menor, outra = (pa, rb) if len(pa) <= len(pb) else (pb, ra) + if any(self.raiz(x) == outra for x in menor): + return False + filho, mae = (ra, rb) if len(pa) <= len(pb) else (rb, ra) + self.pai[filho] = mae + resto = self.proibidos.pop(filho, None) + if resto: + self.proibidos.setdefault(mae, set()).update(resto) + return True + + +def _par(a: int, b: int) -> tuple[int, int]: + return (a, b) if a < b else (b, a) + + +def group_ids( + keys: list[str], + ascii: list[str], + unaccented: list[bool], + links: Iterable[VariantLink] | None, + kind: str, +) -> list[str]: + """group_id de cada chave (R5): o menor name_key do grupo. + + keys: chaves de um kind, ordenadas e sem repetição. ascii: forma ASCII das chaves latinas e + "" nas demais. unaccented: a chave é atestada por fonte com strips_accents (ADR 0011). + """ + posicao = {k: i for i, k in enumerate(keys)} + fase_1 = FASE_1[kind] + juncoes_1: set[tuple[int, int]] = set() + proibicoes: set[tuple[int, int]] = set() + origens: dict[int, set[int]] = defaultdict(set) # destino de transliteração -> origens + for v in links or (): + # R5.1 e R5.2: só o kind pedido, nas duas pontas, e só nós com observação. + if v.kind != kind or v.target_kind != kind: + continue + a, b = posicao.get(v.name_key), posicao.get(v.target_name_key) + if a is None or b is None or a == b: + continue + if v.relation in fase_1: + juncoes_1.add(_par(a, b)) + elif v.relation == "different_from": + proibicoes.add(_par(a, b)) + elif v.relation == "transliteration": + origens[b].add(a) + + grupos = _Grupos(len(keys)) + for a, b in proibicoes: + grupos.proibir(a, b) + for a, b in sorted(juncoes_1): + grupos.juntar(a, b) + + # R5.4: anexações calculadas sobre os grupos da fase 1, aplicadas depois em ordem fixa. + juncoes_2: set[tuple[int, int]] = set() + for destino, fontes in origens.items(): + if len({grupos.raiz(x) for x in fontes}) == 1: + juncoes_2.add(_par(destino, min(fontes))) + sem_acento = {ascii[i] for i, marca in enumerate(unaccented) if marca and ascii[i]} + if sem_acento: + por_ascii: dict[str, list[int]] = defaultdict(list) + for i, forma in enumerate(ascii): + if forma in sem_acento: + por_ascii[forma].append(i) + for i, marca in enumerate(unaccented): + if not (marca and ascii[i]): + continue + candidatos = [j for j in por_ascii[ascii[i]] if j != i] + if candidatos and len({grupos.raiz(j) for j in candidatos}) == 1: + juncoes_2.add(_par(i, min(candidatos))) + for a, b in sorted(juncoes_2): + grupos.juntar(a, b) + + # R5.7: a menor chave do grupo é a primeira na lista ordenada. + menor: dict[int, int] = {} + ids = [] + for i in range(len(keys)): + r = grupos.raiz(i) + primeira = menor.setdefault(r, i) + ids.append(keys[primeira]) + return ids diff --git a/tests/test_variants.py b/tests/test_variants.py new file mode 100644 index 0000000..cf5e956 --- /dev/null +++ b/tests/test_variants.py @@ -0,0 +1,174 @@ +"""Spec 005, R5 (ADR 0010 e 0011): agrupamento de variantes com ligações escritas à mão.""" + +import random + +from namescommons.links import VariantLink +from namescommons.normalize import ascii_form, detect_script, name_key +from namescommons.variants import group_ids + + +def _l(relacao, nome, alvo, kind="given", target_kind=None, qid="Q1", target_qid=None): + if relacao in ("same_item", "transliteration"): + target_qid = qid + return VariantLink( + source_id="wikidata", relation=relacao, qid=qid, name=nome, name_key=name_key(nome), kind=kind, + target_qid=target_qid or "Q2", target_name=alvo, target_name_key=name_key(alvo), + target_kind=target_kind or kind, + ) + + +def _grupos(nomes, ligacoes=(), sem_acento=(), kind="given"): + """nome -> group_id; nomes do índice com ascii só para os latinos, como name_index monta.""" + chaves = sorted({name_key(n) for n in nomes}) + formas = {name_key(n): n for n in nomes} + ascii_ = [ascii_form(formas[k]) if detect_script(formas[k]) == "Latn" else "" for k in chaves] + marcas = [formas[k] in sem_acento for k in chaves] + ids = group_ids(chaves, ascii_, marcas, list(ligacoes), kind) + return {formas[k]: g for k, g in zip(chaves, ids)} + + +def test_nome_sozinho_e_seu_grupo(): + assert _grupos(["Maria", "José"]) == {"Maria": "maria", "José": "josé"} + + +def test_same_item_junta_e_e_nao_dirigido(): + g = _grupos(["Марія", "Maria", "Mariia"], [_l("same_item", "Mariia", "Марія")]) + assert g["Марія"] == g["Mariia"] == "mariia" + assert g["Maria"] == "maria" + + +def test_other_gender_so_em_sobrenomes(): + familia = _grupos(["Иванов", "Иванова"], [_l("other_gender", "Иванов", "Иванова", kind="family")], kind="family") + assert familia["Иванов"] == familia["Иванова"] + prenome = _grupos(["Paulo", "Paula"], [_l("other_gender", "Paulo", "Paula")]) + assert prenome["Paulo"] != prenome["Paula"] + + +def test_transliteracao_inequivoca_junta(): + nomes = ["Иванов", "Иванова", "Ivanov"] + ligacoes = [ + _l("other_gender", "Иванов", "Иванова", kind="family", qid="Q1", target_qid="Q2"), + _l("transliteration", "Иванов", "Ivanov", kind="family", qid="Q1"), + ] + g = _grupos(nomes, ligacoes, kind="family") + assert g["Иванов"] == g["Иванова"] == g["Ivanov"] == "ivanov" + + +def test_romanizacao_ambigua_nao_junta(): + ligacoes = [ + _l("transliteration", "陈", "Chen", kind="family", qid="Q1"), + _l("transliteration", "沈", "Chen", kind="family", qid="Q2"), + ] + g = _grupos(["陈", "沈", "Chen"], ligacoes, kind="family") + assert len(set(g.values())) == 3 + + +def test_romanizacao_de_rotulos_do_mesmo_grupo_junta(): + """Qualificador de um rótulo ligado a todos os rótulos do item (spec 004): o resultado não muda.""" + ligacoes = [ + _l("same_item", "陳", "陈", kind="family"), + _l("transliteration", "陳", "Chen", kind="family"), + _l("transliteration", "陈", "Chen", kind="family"), + ] + g = _grupos(["陳", "陈", "Chen"], ligacoes, kind="family") + assert len(set(g.values())) == 1 + + +def test_sem_acento_junta_marie_eve(): + g = _grupos(["Marie-Eve", "Marie-Ève", "Josee", "Josée"], sem_acento={"Marie-Eve", "Josee"}) + assert g["Marie-Eve"] == g["Marie-Ève"] == "marie-eve" + assert g["Josee"] == g["Josée"] == "josee" + + +def test_sem_acento_ambiguo_nao_junta(): + g = _grupos(["Ines", "Inès", "Inés"], sem_acento={"Ines"}) + assert len(set(g.values())) == 3 + + +def test_sem_acento_com_leituras_do_mesmo_grupo_junta(): + g = _grupos(["Ines", "Inès", "Inés"], [_l("same_item", "Inès", "Inés")], sem_acento={"Ines"}) + assert len(set(g.values())) == 1 + + +def test_acentuados_nao_se_juntam_sem_fonte_sem_acento(): + g = _grupos(["Đức", "Đúc", "Duc"]) + assert len(set(g.values())) == 3 + + +def test_sem_acento_nao_junta_outra_escrita(): + g = _grupos(["Ivan", "Иван"], sem_acento={"Ivan"}) + assert g["Ivan"] != g["Иван"] + + +def test_different_from_separa(): + """A e B são different_from; C é rótulo do mesmo item que A e que B: só uma junção entra.""" + ligacoes = [ + _l("same_item", "Ana", "Anna", qid="Q1"), + _l("same_item", "Anna", "Anne", qid="Q3"), + _l("different_from", "Ana", "Anne", qid="Q1", target_qid="Q3"), + ] + g = _grupos(["Ana", "Anna", "Anne"], ligacoes) + assert g["Ana"] != g["Anne"] + # Ordem fixa: (ana, anna) entra antes de (anna, anne), que é pulada. + assert g["Ana"] == g["Anna"] == "ana" + assert g["Anne"] == "anne" + + +def test_different_from_separa_tambem_na_fase_2(): + ligacoes = [_l("different_from", "Marie-Ève", "Marie-Eve", qid="Q1", target_qid="Q2")] + g = _grupos(["Marie-Eve", "Marie-Ève"], ligacoes, sem_acento={"Marie-Eve"}) + assert g["Marie-Eve"] != g["Marie-Ève"] + + +def test_tipos_diferentes_nunca_juntam(): + ligacoes = [_l("family_name_identical", "John", "John", kind="given", target_kind="family"), + _l("said_to_be_the_same", "Jon", "John", kind="given", target_kind="family")] + assert len(set(_grupos(["John", "Jon"], ligacoes).values())) == 2 + + +def test_p460_nao_junta(): + g = _grupos(["John", "Johann"], [_l("said_to_be_the_same", "John", "Johann")]) + assert g["John"] != g["Johann"] + + +def test_ligacao_sem_observacao_e_ignorada(): + """Mariya só existe como transliteração: não liga Мария a Марія.""" + ligacoes = [_l("transliteration", "Мария", "Mariya", qid="Q1"), + _l("transliteration", "Марія", "Mariya", qid="Q2")] + g = _grupos(["Мария", "Марія"], ligacoes) + assert g["Мария"] != g["Марія"] + + +def test_juncao_pela_chave_e_nao_pelo_texto(): + g = _grupos(["Mckenzie", "Маккензи"], [_l("same_item", "McKenzie", "Маккензи")]) + assert g["Mckenzie"] == g["Маккензи"] + + +def test_resultado_nao_depende_da_ordem(): + nomes = ["Ana", "Anna", "Anne", "Ann", "Annie", "Marie-Eve", "Marie-Ève"] + ligacoes = [ + _l("same_item", "Ana", "Anna", qid="Q1"), + _l("same_item", "Anna", "Anne", qid="Q3"), + _l("same_item", "Ann", "Annie", qid="Q4"), + _l("same_item", "Annie", "Anne", qid="Q4"), + _l("different_from", "Ana", "Anne", qid="Q1", target_qid="Q3"), + _l("different_from", "Ann", "Ana", qid="Q4", target_qid="Q1"), + ] + esperado = _grupos(nomes, ligacoes, sem_acento={"Marie-Eve"}) + for semente in range(20): + embaralhadas = ligacoes[:] + random.Random(semente).shuffle(embaralhadas) + invertidas = [ + _l(v.relation, v.target_name, v.name, qid=v.target_qid, target_qid=v.qid) for v in embaralhadas + ] + assert _grupos(nomes, embaralhadas, sem_acento={"Marie-Eve"}) == esperado + assert _grupos(nomes, invertidas, sem_acento={"Marie-Eve"}) == esperado + + +def test_group_id_e_a_menor_chave(): + ligacoes = [_l("same_item", "Zoë", "Zoe"), _l("same_item", "Zoe", "Ζωή")] + assert set(_grupos(["Zoë", "Zoe", "Ζωή"], ligacoes).values()) == {"zoe"} + + +def test_sem_ligacoes(): + assert group_ids(["a", "b"], ["a", "b"], [False, False], None, "given") == ["a", "b"] From f551cc4df66298f80eb0175d287a884153a2847f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:55:27 -0300 Subject: [PATCH 09/18] =?UTF-8?q?Preenche=20group=5Fid=20no=20=C3=ADndice?= =?UTF-8?q?=20e=20lista=20as=20fontes=20das=20liga=C3=A7=C3=B5es=20no=20ma?= =?UTF-8?q?nifesto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R5 e R7.4: name_index recebe fontes e ligações; cli._build usa a união das fontes das observações e das ligações. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/tasks.md | 6 ++-- src/namescommons/aggregate.py | 14 +++++++++ src/namescommons/cli.py | 6 ++-- tests/test_aggregate.py | 13 +++++++- tests/test_cli_links.py | 52 +++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 0c7e00b..7d3b990 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -58,9 +58,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/aggregate.py`, `src/namescommons/cli.py`, `tests/test_cli_links.py`, `tests/test_aggregate.py`. -- [ ] Testes que falham: `name_index` com ligações e fontes preenche `group_id` em todas as linhas; `build --links` com uma ligação `same_item` grava o mesmo `group_id` nas duas linhas de `given_names.csv`; o `manifest.json` lista a fonte de uma ligação mesmo sem observação dela. -- [ ] `name_index` monta `keys`, `ascii` e a marca de sem acento e chama `group_ids`; `_build` passa fontes e ligações e usa a união das fontes. -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: `name_index` com ligações e fontes preenche `group_id` em todas as linhas; `build --links` com uma ligação `same_item` grava o mesmo `group_id` nas duas linhas de `given_names.csv`; o `manifest.json` lista a fonte de uma ligação mesmo sem observação dela. +- [x] `name_index` monta `keys`, `ascii` e a marca de sem acento e chama `group_ids`; `_build` passa fontes e ligações e usa a união das fontes. +- [x] `uv run pytest -q` e commit. ### Task 8: versão, notas de contrato e CI (R7.1, R7.2, R8, R10) diff --git a/src/namescommons/aggregate.py b/src/namescommons/aggregate.py index b65a4d2..0dc269c 100644 --- a/src/namescommons/aggregate.py +++ b/src/namescommons/aggregate.py @@ -1,11 +1,14 @@ """Saídas agregadas (spec 001, R3): listas por país e índice mundial.""" from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass, replace +from namescommons.links import VariantLink from namescommons.normalize import ascii_form, detect_script from namescommons.schema import Observation from namescommons.sources import Source +from namescommons.variants import group_ids # Ordem de preferência quando o mesmo nome tem contagens de tipos diferentes no país. PRECEDENCIA = ("population", "births") @@ -192,7 +195,9 @@ def name_index( country_rows: dict[str, list[CountryRow]], kind: str, sources: dict[str, Source] | None = None, + links: Iterable[VariantLink] | None = None, ) -> list[IndexRow]: + """Índice mundial de um kind (spec 001, R3.2), com forma de exibição e group_id da spec 005.""" obs_por_chave: dict[str, list[Observation]] = defaultdict(list) for o in observations: if o.kind == kind: @@ -203,6 +208,7 @@ def name_index( if r.kind == kind: linhas_por_chave[r.name_key].append(r) indice = [] + sem_acento = [] for chave in sorted(obs_por_chave): obs = obs_por_chave[chave] rows = sorted(linhas_por_chave.get(chave, []), key=lambda r: r.country) @@ -217,4 +223,12 @@ def name_index( female_share=female / base if base else None, gender_basis=base, group_id=None, sources=tuple(sorted({o.source_id for o in obs})), )) + sem_acento.append(sources is not None and any(sources[f].strips_accents for f in indice[-1].sources)) + # Spec 005, R5: grupos sobre as chaves do índice (já em ordem), com o ascii das latinas. + ids = group_ids( + [r.name_key for r in indice], [r.ascii if r.script == "Latn" else "" for r in indice], + sem_acento, links, kind, + ) + for i, grupo in enumerate(ids): + indice[i] = replace(indice[i], group_id=grupo) return indice diff --git a/src/namescommons/cli.py b/src/namescommons/cli.py index 702f28f..1138f3c 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -118,11 +118,13 @@ def _build( temporario.chmod(0o777 & ~umask) write_observations(obs, temporario / "observations.parquet") for kind, base in (("given", "given_names"), ("family", "family_names")): - write_index(name_index(obs, paises, kind), temporario / f"{base}.csv", temporario / f"{base}.parquet") + write_index(name_index(obs, paises, kind, fontes, links), temporario / f"{base}.csv", temporario / f"{base}.parquet") write_by_country(paises, temporario / "by_country") if links is not None: write_links(links, temporario / "variant_links.parquet") - manifest = build_manifest(release, temporario, fontes, {o.source_id for o in obs}) + # Spec 005, R7.4: fontes das observações e das ligações. + usadas = {o.source_id for o in obs} | {v.source_id for v in links or ()} + manifest = build_manifest(release, temporario, fontes, usadas) (temporario / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index ac6c35a..55a0ea4 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -113,7 +113,7 @@ def test_indice_given(observacoes, fontes): assert maria.female_share == pytest.approx(12150 / 12155) assert maria.gender_basis == 12155 assert maria.sources == ("br-ibge-censo", "us-births-b", "us-ssa", "wikidata") - assert maria.group_id is None + assert maria.group_id == "maria" # spec 005, R5.7: sem ligações, o grupo é a própria chave def test_indice_nome_so_atestado(observacoes, fontes): @@ -235,3 +235,14 @@ def test_forma_publicada_vence_a_capitalizacao_gerada(): def test_indice_sem_fontes_mantem_todas_as_formas_candidatas(observacoes, fontes): indice = name_index(observacoes, by_country(observacoes, fontes), "given") assert indice[1].name == "Deshawn" + + +def test_indice_preenche_group_id_com_fontes_e_ligacoes(observacoes, fontes): + """Spec 005, R5: group_id em todas as linhas; same_item junta pela chave.""" + from namescommons.links import VariantLink + ligacao = VariantLink("wikidata", "same_item", "Q1", "Maria", "maria", "given", + "Q1", "Мария", "мария", "given") + indice = name_index(observacoes, by_country(observacoes, fontes), "given", sources=fontes, links=[ligacao]) + assert {r.name_key: r.group_id for r in indice} == { + "ana clara": "ana clara", "deshawn": "deshawn", "james": "james", "maria": "maria", "мария": "maria", + } diff --git a/tests/test_cli_links.py b/tests/test_cli_links.py index e78c24e..1cc607a 100644 --- a/tests/test_cli_links.py +++ b/tests/test_cli_links.py @@ -80,3 +80,55 @@ def test_run_grava_variant_links_so_com_fonte_de_ligacoes(tmp_path): assert main(["run", "--offline", "--cache", str(cache), "--sources", str(sem), "--out", str(tmp_path / "dist2"), "--release", "2026.10"]) == 0 assert not (tmp_path / "dist2" / "variant_links.parquet").exists() + + +def _grupos(caminho: Path) -> dict[str, str]: + import csv + with open(caminho, encoding="utf-8", newline="") as fh: + return {linha["name"]: linha["group_id"] for linha in csv.DictReader(fh)} + + +def test_build_links_preenche_group_id(cache, tmp_path): + """Spec 005, R5 e critério de aceite 6, sobre a amostra real do Wikidata.""" + obs, ligacoes = tmp_path / "t" / "observations.parquet", tmp_path / "t" / "variant_links.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, + "--out", str(obs), "--links", str(ligacoes)]) == 0 + dist = tmp_path / "dist" + assert main(["build", str(obs), "--links", str(ligacoes), "--sources", FONTES, + "--out", str(dist), "--release", "2026.10"]) == 0 + sobrenomes = _grupos(dist / "family_names.csv") + # other_gender em sobrenome (fase 1) e transliterações inequívocas (fase 2) + assert {n: sobrenomes[n] for n in ("Ivanov", "Ivanova", "Иванов", "Иванова")} == dict.fromkeys( + ("Ivanov", "Ivanova", "Иванов", "Иванова"), "ivanov") + assert sobrenomes["Silva"] == "silva" and sobrenomes["李"] == "李" + prenomes = _grupos(dist / "given_names.csv") + assert prenomes["Maria"] == prenomes["Мария"] == "maria" # transliteração; other_gender em prenome não junta + # P460 não junta (Иван, Ivan e John), e prenome não se junta a sobrenome. + assert (prenomes["Ivan"], prenomes["Иван"], prenomes["John"]) == ("ivan", "иван", "john") + assert all(prenomes.values()) and all(sobrenomes.values()) + + +def test_build_sem_links_preenche_group_id_com_a_propria_chave(cache, tmp_path): + obs = tmp_path / "observations.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, "--out", str(obs)]) == 0 + dist = tmp_path / "dist" + assert main(["build", str(obs), "--sources", FONTES, "--out", str(dist), "--release", "2026.10"]) == 0 + prenomes = _grupos(dist / "given_names.csv") + assert prenomes["Мария"] == "мария" and prenomes["Maria"] == "maria" + + +def test_manifesto_lista_a_fonte_das_ligacoes(cache, tmp_path): + """Spec 005, R7.4: a fonte das ligações entra no manifesto mesmo sem observação dela.""" + from namescommons.io import write_observations + from namescommons.schema import make_observation + ligacoes = tmp_path / "variant_links.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, + "--out", str(tmp_path / "wd.parquet"), "--links", str(ligacoes)]) == 0 + obs = tmp_path / "observations.parquet" + write_observations([make_observation("Maria", kind="given", source_id="us-ssa-births", country="US", + count_type="births", gender="F", year=2020, count=10)], obs) + dist = tmp_path / "dist" + assert main(["build", str(obs), "--links", str(ligacoes), "--sources", FONTES, + "--out", str(dist), "--release", "2026.10"]) == 0 + manifest = json.loads((dist / "manifest.json").read_text(encoding="utf-8")) + assert [f["id"] for f in manifest["sources"]] == ["us-ssa-births", "wikidata"] From 89b6dbd6ce1dbc1d73e395451319b647591d1730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 14:56:48 -0300 Subject: [PATCH 10/18] Passa SCHEMA_VERSION a 1.2.0, confere group_id na CI e aponta os contratos das specs 001 e 002 Spec 005, R7.1, R7.2, R8 e R10. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 3 +++ docs/specs/001-esquema/spec.md | 6 ++++++ docs/specs/002-pipeline/spec.md | 2 ++ docs/specs/005-variantes/tasks.md | 6 +++--- src/namescommons/schema.py | 4 +++- tests/test_ci.py | 8 ++++++++ tests/test_cli_links.py | 2 +- tests/test_io.py | 2 +- tests/test_links.py | 2 +- 9 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ea1b19..3e7c79e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: --out "$RUNNER_TEMP/dist-oficiais" --release "$(date -u +%Y.%m)" for pais in CA ES FR IE NO NZ US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done test -s "$RUNNER_TEMP/dist-oficiais/variant_links.parquet" + for indice in given_names family_names; do + uv run python -c 'import csv, sys; linhas = list(csv.DictReader(open(sys.argv[1], encoding="utf-8"))); sys.exit(not linhas or not all(l["group_id"] for l in linhas))' "$RUNNER_TEMP/dist-oficiais/$indice.csv" + done diff --git a/docs/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index cc16dca..ff77f2c 100644 --- a/docs/specs/001-esquema/spec.md +++ b/docs/specs/001-esquema/spec.md @@ -60,6 +60,8 @@ Nota (spec 004, R5): a exigência de exatamente uma `primary` vale só para `bir | `group_id` | Grupo de variantes, preenchido pela spec 005. Vazio até lá | | `sources` | Lista ordenada de `source_id` que atestam o nome | +Nota (spec 005): `group_id` é preenchido pela [spec 005](../005-variantes/spec.md) (R5), a forma de exibição (`name`) segue a spec 005 (R4) e `ascii` segue o [ADR 0012](../../adr/0012-transliteracao-ascii.md). + **R3.3 `by_country/.csv`.** Uma linha por (`name_key`, `kind`) do país, com `name`, `ascii`, `script`, `count`, `count_type`, `rank`, `female_share`, `gender_basis`, `first_year`, `last_year`. Agregação: - `births`: soma de todos os anos disponíveis; `first_year` e `last_year` delimitam a série. @@ -69,6 +71,8 @@ Nota (spec 004, R5): a exigência de exatamente uma `primary` vale só para `bir - País só com `attested`: `count` e `rank` vazios. - `rank` é denso, por `kind` e `count_type`, em ordem decrescente de `count`. +Nota (spec 004, R4.6): além das saídas acima, a release pode trazer `variant_links.parquet`, a tabela de ligações entre variantes quando alguma fonte selecionada implementa `links`. + ### R4. Layout do repositório ``` @@ -95,6 +99,8 @@ docs/specs/, docs/adr/ - Cada release inclui `manifest.json` com `schema_version` (semver), fontes usadas com `retrieved`, número de linhas por arquivo e SHA-256 de cada arquivo. - Remover ou renomear campo, ou mudar o significado de um valor, incrementa a versão maior de `schema_version`. +Nota: a release pode incluir `variant_links.parquet` (spec 004, R4.6), contado no `manifest.json`, cuja lista de fontes é a união das fontes das observações e das ligações (spec 005, R7.4). Versões: `1.1.0` (spec 004, R4.7) e `1.2.0` (spec 005, R8). + ### R6. Legado `first_names.all.txt`, `last_names.all.txt` e `generate/` vão para `legacy/` sem alteração de conteúdo e não são ingeridos no dataset novo (ADR 0001). A retirada é decidida na spec 006. diff --git a/docs/specs/002-pipeline/spec.md b/docs/specs/002-pipeline/spec.md index 42e2dad..00e22ba 100644 --- a/docs/specs/002-pipeline/spec.md +++ b/docs/specs/002-pipeline/spec.md @@ -30,6 +30,8 @@ Cada fonte de `sources.yaml` com adaptador tem um módulo `src/namescommons/adap - **R1.4** Registros repetidos pela chave de unicidade da 001 (R1.4) são somados. - **R1.5** Uma fonte não pode emitir, para o mesmo (`name_key`, `kind`, `country`, `year`), linhas com `gender` informado e linhas sem `gender`. A violação falha a ingestão daquela fonte. +Nota (spec 004, R4.1): o módulo pode expor também a função opcional `links(files) -> Iterator[RawLink]`, com as ligações entre variantes; o contrato dela é da spec 004. + Nota: a spec 003 (R2.1) esclarece que o adaptador converte os valores; "como a fonte publica" vale só para o nome. ### R2. Extensões de `sources.yaml` diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 7d3b990..bf3d5e5 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -66,9 +66,9 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra **Arquivos:** `src/namescommons/schema.py`, `tests/test_manifest.py`, `tests/test_io.py`, `.github/workflows/ci.yml`, `tests/test_ci.py`, `docs/specs/001-esquema/spec.md`, `docs/specs/002-pipeline/spec.md`. -- [ ] Testes que falham: `schema_version` `1.2.0`; o passo da CI das fontes oficiais confere `group_id` em todas as linhas de `given_names.csv`. -- [ ] Notas na spec 001 (R3 e R5) e na spec 002 (R1). -- [ ] `uv run pytest -q` e commit. +- [x] Testes que falham: `schema_version` `1.2.0`; o passo da CI das fontes oficiais confere `group_id` em todas as linhas de `given_names.csv`. +- [x] Notas na spec 001 (R3 e R5) e na spec 002 (R1). +- [x] `uv run pytest -q` e commit. ### Fechamento diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index b36b801..5371f28 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -7,7 +7,9 @@ from namescommons.normalize import ascii_form, detect_script, name_key, normalize_name from namescommons.sources import COUNT_TYPES, Source, is_country -SCHEMA_VERSION = "1.1.0" # 1.1.0: variant_links.parquet na release (spec 004, R4.7) +# 1.1.0: variant_links.parquet na release (spec 004, R4.7). +# 1.2.0: group_id preenchido, ascii em cirílico, Jpan e Kore, partículas (spec 005, R8). +SCHEMA_VERSION = "1.2.0" KINDS = ("given", "family") GENDERS = ("F", "M") ANO_MINIMO = 1800 diff --git a/tests/test_ci.py b/tests/test_ci.py index 8666252..c66cf46 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -1,5 +1,6 @@ """Spec 002, R7: o workflow da CI roda uv sync, pytest e namescommons run --offline sobre as fixtures.""" +import csv import os import re import shutil @@ -172,6 +173,13 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): # Spec 004, R7: o Wikidata entra no passo e grava a tabela de ligações. assert "variant_links.parquet" in passo["run"] assert (tmp_path / "dist-oficiais" / "variant_links.parquet").stat().st_size > 0 + # Spec 005, R10: group_id preenchido em todas as linhas do índice. + assert "group_id" in passo["run"] + for arquivo in ("given_names.csv", "family_names.csv"): + with open(tmp_path / "dist-oficiais" / arquivo, encoding="utf-8", newline="") as fh: + grupos = {linha["name"]: linha["group_id"] for linha in csv.DictReader(fh)} + assert grupos and all(grupos.values()) + assert grupos["Иванов"] == grupos["Ivanov"] == "ivanov" def test_passo_das_fontes_oficiais_confere_todos_os_paises(): diff --git a/tests/test_cli_links.py b/tests/test_cli_links.py index 1cc607a..10131de 100644 --- a/tests/test_cli_links.py +++ b/tests/test_cli_links.py @@ -33,7 +33,7 @@ def test_ingest_links_e_build_links(cache, tmp_path, capsys): "--out", str(dist), "--release", "2026.10"]) == 0 assert read_links(dist / "variant_links.parquet") == links manifest = json.loads((dist / "manifest.json").read_text(encoding="utf-8")) - assert manifest["schema_version"] == "1.1.0" + assert manifest["schema_version"] == "1.2.0" assert manifest["files"]["variant_links.parquet"]["rows"] == 47 diff --git a/tests/test_io.py b/tests/test_io.py index f3152a3..91be513 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -51,7 +51,7 @@ def test_manifest(observacoes, fontes, tmp_path): write_by_country(by_country(observacoes, fontes), tmp_path / "by_country") write_observations(observacoes, tmp_path / "observations.parquet") m = build_manifest("2026.10", tmp_path, fontes, {o.source_id for o in observacoes}) - assert m["schema_version"] == "1.1.0" + assert m["schema_version"] == "1.2.0" assert [s["id"] for s in m["sources"]] == ["br-ibge-censo", "us-births-b", "us-ssa", "wikidata"] assert m["files"]["observations.parquet"]["rows"] == len(observacoes) assert m["files"]["by_country/US.csv"]["rows"] == 4 diff --git a/tests/test_links.py b/tests/test_links.py index a5e1610..5c24bca 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -121,7 +121,7 @@ def test_ida_e_volta_em_parquet(tmp_path): def test_versao_do_esquema(): - assert SCHEMA_VERSION == "1.1.0" + assert SCHEMA_VERSION == "1.2.0" # 1.1.0 na spec 004; 1.2.0 na spec 005, R8 def _modulo(**extra): From 9a84f6f146d84eebfe315bc6ca3be1afaf28f87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:11:07 -0300 Subject: [PATCH 11/18] =?UTF-8?q?Grava=20as=20observa=C3=A7=C3=B5es=20em?= =?UTF-8?q?=20lotes=20e=20inclui=20o=20Wikidata=20na=20medi=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20R8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 005, R9.3 e R9.4 (issue #31): o pico do run vinha da conversão de todas as observações para colunas de uma vez; tools/bench_pipeline.py ganha --wikidata. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/plan.md | 5 +++- docs/specs/005-variantes/spec.md | 2 ++ docs/specs/005-variantes/tasks.md | 8 +++++++ src/namescommons/io.py | 14 +++++++---- tests/test_benchmark.py | 31 ++++++++++++++++++++++++ tests/test_cli_pipeline.py | 15 +++++++++++- tools/bench_pipeline.py | 39 ++++++++++++++++++++++++++++--- 7 files changed, 105 insertions(+), 9 deletions(-) diff --git a/docs/specs/005-variantes/plan.md b/docs/specs/005-variantes/plan.md index 00f60e0..15bd5da 100644 --- a/docs/specs/005-variantes/plan.md +++ b/docs/specs/005-variantes/plan.md @@ -26,6 +26,7 @@ | A forma ASCII do nó é a de `ascii_form` sobre a forma exibida | Observações lidas de um Parquet antigo podem ter `ascii` da regra anterior; a forma exibida é a mesma que vai para o índice | | `CountryRow` ganha o campo interno `published_case` (não sai no CSV) | O índice precisa saber se a forma exibida no país veio de fonte de caixa publicada (R4.3) sem reler as observações | | `name_index(observations, country_rows, kind, sources=None, links=None)` | Chamadas existentes sem fontes e sem ligações continuam válidas: tratam toda fonte como de caixa publicada e sem remoção de acento, e não usam ligações | +| `write_observations` com `ParquetWriter` e lotes de 250.000 linhas | O pico do `run` estava na conversão de todas as observações para colunas Python e Arrow de uma vez (R9.3); em lotes, o arquivo tem vários grupos de linhas e o mesmo conteúdo | | `validate` confere `ascii` só como ASCII sem maiúsculas | Recalcular `ascii_form` para 4 milhões de observações custaria tempo e memória; a coerência vem de `derive_name_fields` | | Partículas só depois de espaço e fora da primeira palavra | Medido no INE: 1.449 `De`, 432 `La`, 121 `Del`; `Jia Le` mostra que artigo isolado também é nome | @@ -48,6 +49,7 @@ | `src/namescommons/aggregate.py`, `tests/test_aggregate.py` | Forma de exibição (R4) | 5 | | `src/namescommons/variants.py`, `tests/test_variants.py` | `group_ids` (R5) | 6 | | `src/namescommons/aggregate.py`, `src/namescommons/cli.py`, `tests/test_cli_links.py` | `group_id` no índice, manifesto com as fontes das ligações (R5, R7.4) | 7 | +| `src/namescommons/io.py`, `tools/bench_pipeline.py`, `tests/test_benchmark.py` | Escrita em lotes e Wikidata na medição (R9.3, R9.4; issue #31) | 9 | | `src/namescommons/schema.py`, `tests/test_manifest.py`, `.github/workflows/ci.yml`, `tests/test_ci.py`, `docs/specs/001-esquema/spec.md`, `docs/specs/002-pipeline/spec.md` | Versão (R8), notas de contrato (R7.1, R7.2) e CI (R10) | 8 | ## Cobertura da spec @@ -64,7 +66,8 @@ | R7.3 | 4 | | R7.4 | 7 | | R8 | 8 | -| R9 | Fechamento | +| R9.1, R9.2 | Fechamento | +| R9.3, R9.4 | 9 | | R10 | 1 a 8 | | Critério de aceite 1 | 1 | | Critério 2 | 2 | diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index 3f3c9d1..728393c 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -92,6 +92,8 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid - **R9.1** O agrupamento trabalha sobre os nós (`kind`, `name_key`), um `kind` por vez, com inteiros e as chaves que o índice já tem; não cria outra cópia das observações nem das ligações. - **R9.2** `run --offline` com o lote real completo, e num único `run` com as 3 milhões de observações sintéticas de `tools/bench_pipeline.py` mais o Wikidata real, fica dentro de R8 da spec 002 (menos de 10 minutos e de 4 GB), e o pico de memória não passa do medido na master nas mesmas condições. Se passar, esta spec reduz o pico (issue #31). A medição vai para o pull request. +- **R9.3** Medido em 2026-10-12, com as fases instrumentadas no `run` sintético mais Wikidata: o pico vem de `write_observations`, que monta 11 listas Python de 3,8 milhões de valores e a tabela Arrow inteira antes de gravar (de 2.635 MB para 3.217 MB). `write_observations` passa a gravar em lotes de no máximo 250.000 observações, com o mesmo esquema e o mesmo conteúdo. +- **R9.4** `tools/bench_pipeline.py` ganha a opção `--wikidata DIR`, que inclui o Wikidata com os arquivos de `DIR` (o cache real) na medição, com `ingest --links` e `build --links` (issue #31). ### R10. Testes e CI diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index bf3d5e5..27ff8a6 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -70,6 +70,14 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra - [x] Notas na spec 001 (R3 e R5) e na spec 002 (R1). - [x] `uv run pytest -q` e commit. +### Task 9: memória do `run` (R9.3, R9.4; issue #31) + +**Arquivos:** `src/namescommons/io.py`, `tools/bench_pipeline.py`, `tests/test_benchmark.py`. + +- [x] Testes que falham: `write_observations` com lote de 2 grava e relê as mesmas observações, em vários grupos de linhas; arquivo vazio continua com o esquema; `bench_pipeline --wikidata` com a amostra de `tests/fixtures/wikidata` mede e grava `variant_links.parquet`. +- [x] `write_observations` com `pq.ParquetWriter` e `LOTE_DE_ESCRITA = 250_000`; `--wikidata` no bench. +- [x] `uv run pytest -q` e commit. + ### Fechamento - [ ] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. diff --git a/src/namescommons/io.py b/src/namescommons/io.py index a6f0766..e440849 100644 --- a/src/namescommons/io.py +++ b/src/namescommons/io.py @@ -44,15 +44,21 @@ ] +# Spec 005, R9.3 (issue #31): observações gravadas em lotes, sem montar a tabela inteira. +LOTE_DE_ESCRITA = 250_000 + + def write_observations(observations: list[Observation], path: Path) -> None: - # Colunar: evita um dicionário por observação (R8, spec 002). - colunas = {campo: [getattr(o, campo) for o in observations] for campo in OBSERVATION_SCHEMA.names} - tabela = pa.Table.from_pydict(colunas, schema=OBSERVATION_SCHEMA) path.parent.mkdir(parents=True, exist_ok=True) # Grava ao lado e troca por rename: uma falha não deixa arquivo parcial nem apaga o anterior. temporario = path.with_name(path.name + ".part") try: - pq.write_table(tabela, temporario) + with pq.ParquetWriter(temporario, OBSERVATION_SCHEMA) as escritor: + for inicio in range(0, max(len(observations), 1), LOTE_DE_ESCRITA): + lote = observations[inicio:inicio + LOTE_DE_ESCRITA] + # Colunar: evita um dicionário por observação (R8, spec 002). + colunas = {campo: [getattr(o, campo) for o in lote] for campo in OBSERVATION_SCHEMA.names} + escritor.write_table(pa.Table.from_pydict(colunas, schema=OBSERVATION_SCHEMA)) os.replace(temporario, path) finally: temporario.unlink(missing_ok=True) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 4fdd776..28f5fa6 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -69,3 +69,34 @@ def test_falha_numa_etapa_imprime_a_linha_r8_e_o_diretorio(tmp_path, capsys, mon assert bench.main(["--observacoes", "120", "--dir", str(tmp_path / "b")]) == 1 saida = capsys.readouterr().out assert saida.startswith("R8: FALHA no ingest (código 1)") and str(tmp_path / "b") in saida + + +def test_escrita_em_lotes_relê_as_mesmas_observacoes(observacoes, tmp_path, monkeypatch): + """Spec 005, R9.3 (issue #31): write_observations grava em lotes.""" + import pyarrow.parquet as pq + import namescommons.io as io_ + monkeypatch.setattr(io_, "LOTE_DE_ESCRITA", 2) + p = tmp_path / "observations.parquet" + write_observations(observacoes, p) + assert read_observations(p) == observacoes + assert pq.ParquetFile(p).metadata.num_row_groups == (len(observacoes) + 1) // 2 + + +def test_escrita_sem_observacoes_mantem_o_esquema(tmp_path): + import pyarrow.parquet as pq + from namescommons.io import OBSERVATION_SCHEMA + p = tmp_path / "observations.parquet" + write_observations([], p) + assert pq.read_table(p).schema.equals(OBSERVATION_SCHEMA) and read_observations(p) == [] + + +def test_bench_com_wikidata(tmp_path, capsys): + """Spec 005, R9.4 (issue #31): o Wikidata entra na medição com as ligações.""" + registro = tmp_path / "r8.json" + assert _bench().main(["--observacoes", "120", "--dir", str(tmp_path / "b"), "--registro", str(registro), + "--wikidata", str(RAIZ / "tests" / "fixtures" / "wikidata")]) == 0 + saida = capsys.readouterr().out + assert "120 observações sintéticas mais o Wikidata; ingest " in saida and "dentro dos limites" in saida + assert (tmp_path / "b" / "dist" / "variant_links.parquet").is_file() + dados = json.loads(registro.read_text(encoding="utf-8")) + assert dados["observacoes"] == 120 and dados["wikidata"] is True diff --git a/tests/test_cli_pipeline.py b/tests/test_cli_pipeline.py index b3ba602..b9eba55 100644 --- a/tests/test_cli_pipeline.py +++ b/tests/test_cli_pipeline.py @@ -213,7 +213,20 @@ def falha(observacoes, caminho): caminho.write_bytes(b"parcial") raise OSError("disco cheio") - monkeypatch.setattr("namescommons.io.pq.write_table", lambda *a, **k: falha(None, a[1])) + class EscritorQueFalha: # spec 005, R9.3: a escrita é em lotes, por ParquetWriter + def __init__(self, caminho, esquema): + self.caminho = caminho + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def write_table(self, tabela): + falha(None, self.caminho) + + monkeypatch.setattr("namescommons.io.pq.ParquetWriter", EscritorQueFalha) args = ["ingest", "--out", str(obs), "--offline", "--cache", str(cache_das_fixtures), *ADAPTADORES] assert main(args) == 1 assert obs.read_bytes() == b"anterior" diff --git a/tools/bench_pipeline.py b/tools/bench_pipeline.py index 263cc8a..63f9084 100644 --- a/tools/bench_pipeline.py +++ b/tools/bench_pipeline.py @@ -5,6 +5,10 @@ Não roda na CI. Uso: uv run python tools/bench_pipeline.py [--observacoes 3000000] [--dir DIR] [--registro r8.json] + [--wikidata CACHE/wikidata] + +--wikidata inclui na mesma medição o Wikidata com os arquivos já baixados no diretório indicado +(spec 005, R9.4; issue #31), com ingest --links e build --links. """ import argparse @@ -59,6 +63,30 @@ def parse(files): ''' +WIKIDATA_YAML = """\ + - id: wikidata + name: Wikidata + url: https://www.wikidata.org/wiki/Wikidata:Licensing + license: CC0-1.0 + attribution: Wikidata + country: null + count_type: attested + primary: false + suppression: null + retrieved: 2026-10-12 +""" + +WIKIDATA_ADAPTADOR = "from namescommons.adapters.wikidata import SOURCE_ID, RESOURCES, parse, links # noqa: F401\n" + + +def _incluir_wikidata(base: Path, origem: Path) -> None: + """Fonte, adaptador e cache do Wikidata no diretório da medição (o cache é um link para origem).""" + with open(base / "sources.yaml", "a", encoding="utf-8") as fh: + fh.write(WIKIDATA_YAML) + (base / "bench_adaptadores" / "wikidata.py").write_text(WIKIDATA_ADAPTADOR, encoding="utf-8") + (base / "cache" / "wikidata").symlink_to(origem.resolve(), target_is_directory=True) + + def total_de_observacoes(pedidas: int) -> int: """Maior múltiplo de BLOCO que não passa de pedidas (no mínimo um bloco).""" return max(1, pedidas // BLOCO) * BLOCO @@ -112,6 +140,7 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--observacoes", type=int, default=3_000_000) ap.add_argument("--dir", type=Path, help="diretório de trabalho (padrão: temporário)") ap.add_argument("--registro", type=Path, help="grava o resultado em JSON") + ap.add_argument("--wikidata", type=Path, help="diretório com os arquivos do Wikidata já baixados") args = ap.parse_args(argv) total = total_de_observacoes(args.observacoes) base = args.dir or Path(tempfile.mkdtemp(prefix="namescommons-r8-")) @@ -122,19 +151,22 @@ def main(argv: list[str] | None = None) -> int: inicio = time.perf_counter() _gerar(base, total) + if args.wikidata: + _incluir_wikidata(base, args.wikidata) + ligacoes = ["--links", str(base / "variant_links.parquet")] if args.wikidata else [] geracao = time.perf_counter() - inicio comum = ["--sources", str(base / "sources.yaml")] sys.path.insert(0, str(base)) try: inicio = time.perf_counter() codigo = namescommons(["ingest", "--offline", "--out", str(base / "observations.parquet"), - "--cache", str(base / "cache"), "--adapters", "bench_adaptadores", *comum]) + "--cache", str(base / "cache"), "--adapters", "bench_adaptadores", *ligacoes, *comum]) ingest = time.perf_counter() - inicio if codigo != 0: return _falha("ingest", codigo, base) inicio = time.perf_counter() codigo = namescommons(["build", str(base / "observations.parquet"), "--out", str(base / "dist"), - "--release", "2026.10", *comum]) + "--release", "2026.10", *ligacoes, *comum]) build = time.perf_counter() - inicio if codigo != 0: return _falha("build", codigo, base) @@ -149,9 +181,10 @@ def main(argv: list[str] | None = None) -> int: "observacoes": total, "geracao_s": round(geracao, 1), "ingest_s": round(ingest, 1), "build_s": round(build, 1), "memoria_pico_mb": round(memoria), "python": platform.python_version(), "maquina": f"{platform.system()} {platform.machine()}", "dentro_dos_limites": dentro, + "wikidata": bool(args.wikidata), } print( - f"R8: {total} observações; ingest {ingest:.1f} s, build {build:.1f} s, " + f"R8: {total} observações" + (" sintéticas mais o Wikidata" if args.wikidata else "") + f"; ingest {ingest:.1f} s, build {build:.1f} s, " f"pico de memória {memoria:.0f} MB; " + ("dentro dos limites" if dentro else f"FORA dos limites ({LIMITE_SEGUNDOS} s, {LIMITE_MB} MB)") ) From 0cfe3c6eede92a5795576764e2cc242378dd84a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:19:39 -0300 Subject: [PATCH 12/18] docs(variants): record measurements and follow-up issues for spec 005 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/spec.md | 12 ++++++------ docs/specs/005-variantes/tasks.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index 728393c..ec46bcc 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -28,7 +28,7 @@ As fontes oficiais da Statistics Canada, do INE e da SSA publicam os nomes sem a - **R2.1 Latim.** Decomposição NFKD (ligaduras e dígrafos de compatibilidade como `Dž`, `fi` e letras de largura total viram letras simples), remoção das marcas combinantes e a tabela de letras sem decomposição: `ß` `ss`, `ẞ` `ss`, `ø` `o`, `æ` `ae`, `œ` `oe`, `đ` `d`, `ð` `d`, `ł` `l`, `þ` `th`, `ı` `i`, `ħ` `h`, `ŧ` `t`, `ŋ` `n`, `ə` `e`, `ɛ` `e`, `ɔ` `o`, `ɣ` `g`, `ʙ` `b`. Os valores seguem a tabela A da seção 6 do ICAO Doc 9303 (parte 3, 8.ª edição, 2021) onde ela tem a letra, com a forma de uma letra só onde ela dá alternativa (`Ä` "AE or A" vira `a`), e as letras de alfabetos africanos ausentes do ICAO (`ə`, `ɛ`, `ɔ`, `ɣ`) usam a vogal ou consoante latina de mesma base. - **R2.2 Cirílico.** A tabela B da mesma seção do ICAO Doc 9303, na forma padrão de cada letra (sem as exceções por língua, porque o dataset não sabe a língua do nome): `ж` `zh`, `х` `kh`, `ц` `ts`, `ч` `ch`, `ш` `sh`, `щ` `shch`, `ъ` `ie`, `ы` `y`, `э` `e`, `ю` `iu`, `я` `ia`, `ё` `e`, `й` `i`, `є` `ie`, `ї` `i`, `ђ` `d`, `ѕ` `dz`, `ј` `j`, `љ` `lj`, `њ` `nj`, `ќ` `k`, `ў` `u`, `џ` `dz`, `ґ` `g`, `ғ` `g`, `һ` `c`, `ѫ` `u`, `ѵ` `y` e as letras restantes do russo como na tabela. `ь` não tem transliteração e é omitido. `ћ`, ausente da tabela, vira `c`, como `ć` do alfabeto latino sérvio que lhe corresponde. - **R2.3 Pontuação do nome.** Caracteres ASCII ficam como estão (em minúsculas), exceto a crase, que vira `'`. Apóstrofos tipográficos e modificadores (`’`, `‘`, `ʼ`, `ʻ`, `ʹ`, `ʾ`, `ʿ`, `` ` ``, `´`) viram `'`. Hífens e travessões Unicode (`‐`, `‑`, `‒`, `–`) viram `-`. O ponto médio (`·`, do catalão `l·l`) e caracteres de formatação (categoria `Cf`, como o ZWJ) são omitidos. -- **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias por enquanto (issue de acompanhamento). Medido no lote real: 8 nomes latinos ficam vazios (letra grega ou tailandesa no meio do nome, `¶`), e 332 nomes cirílicos também, por letras do cazaque, tártaro e tadjique fora da tabela do ICAO (`ә`, `қ`, `ү`, `ҳ`). +- **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias (issue #35). Medido no lote real: 8 nomes latinos ficam vazios (letra grega ou tailandesa no meio do nome, `¶`), e 332 nomes cirílicos também, por letras do cazaque, tártaro e tadjique fora da tabela do ICAO (`ә`, `қ`, `ү`, `ҳ`). - **R2.5 Validação.** `validate` deixa de exigir `ascii` não vazio em escrita latina e passa a exigir que `ascii` só tenha caracteres ASCII sem maiúsculas. ### R3. Capitalização das fontes de caixa uniforme @@ -63,7 +63,7 @@ Cada linha de `given_names` e `family_names` recebe `group_id`. O agrupamento é - **R5.5 Não juntam.** `said_to_be_the_same` (P460) não junta: o fecho transitivo dessas ligações forma um componente de 14.039 nomes mesmo com os cortes de R5.6 (medido nos dados reais, ADR 0010). Nomes que só diferem em acento, sem fonte que remove acentos, também não juntam. - **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções das fases 1 e 2 são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. - **R5.7 Identificador.** `group_id` é o menor `name_key` do grupo em ordem de código Unicode (`marie-eve` para `Marie-Eve` e `Marie-Ève`). Nome sem variante forma um grupo sozinho, e seu `group_id` é o próprio `name_key`. Sem `variant_links` (`build` sem `--links`), só a regra sem acento junta nomes. -- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata, 1.008.182 nós), a simulação destas regras anexa 3.347 transliterações (816 ambíguas ficam de fora) e 14.394 nomes sem acento (2.883 ambíguos ficam de fora), pula 292 junções por `different_from` e forma 35.759 grupos com mais de um nome, o maior com 31 (um sobrenome coreano escrito com hanjas diferentes no mesmo item do Wikidata). Os números finais da implementação vão para o pull request. +- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata, 1.008.182 nós), a simulação destas regras anexa 3.347 transliterações (816 ambíguas ficam de fora) e 14.394 nomes sem acento (2.883 ambíguos ficam de fora), pula 292 junções por `different_from` e forma 35.759 grupos com mais de um nome, o maior com 31 (um sobrenome coreano escrito com hanjas diferentes no mesmo item do Wikidata). Com a implementação, no mesmo lote: 8.270 grupos com variantes em prenomes (17.943 nomes; maior com 31) e 27.459 em sobrenomes (65.384 nomes; maior com 17). ### R6. Fontes que removem acentos (`strips_accents`) @@ -91,7 +91,7 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ### R9. Desempenho e memória - **R9.1** O agrupamento trabalha sobre os nós (`kind`, `name_key`), um `kind` por vez, com inteiros e as chaves que o índice já tem; não cria outra cópia das observações nem das ligações. -- **R9.2** `run --offline` com o lote real completo, e num único `run` com as 3 milhões de observações sintéticas de `tools/bench_pipeline.py` mais o Wikidata real, fica dentro de R8 da spec 002 (menos de 10 minutos e de 4 GB), e o pico de memória não passa do medido na master nas mesmas condições. Se passar, esta spec reduz o pico (issue #31). A medição vai para o pull request. +- **R9.2** `run --offline` com o lote real completo, e num único `run` com as 3 milhões de observações sintéticas de `tools/bench_pipeline.py` mais o Wikidata real, fica dentro de R8 da spec 002 (menos de 10 minutos e de 4 GB), e o pico de memória não passa do medido na master nas mesmas condições. Se passar, esta spec reduz o pico (issue #31). A medição vai para o pull request. Medido em 2026-10-12 (Apple M4, 24 GB, pico de memória física do processo): lote real completo, 4.336.051 observações, master 3,35 GB e esta spec 2,89 GB; sintético mais Wikidata, 3.816.771 observações, master 3,47 GB e esta spec 2,79 GB. - **R9.3** Medido em 2026-10-12, com as fases instrumentadas no `run` sintético mais Wikidata: o pico vem de `write_observations`, que monta 11 listas Python de 3,8 milhões de valores e a tabela Arrow inteira antes de gravar (de 2.635 MB para 3.217 MB). `write_observations` passa a gravar em lotes de no máximo 250.000 observações, com o mesmo esquema e o mesmo conteúdo. - **R9.4** `tools/bench_pipeline.py` ganha a opção `--wikidata DIR`, que inclui o Wikidata com os arquivos de `DIR` (o cache real) na medição, com `ingest --links` e `build --links` (issue #31). @@ -115,8 +115,8 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ## Fora de escopo -- Transliteração de grego, hangul, kana, árabe, hebraico, han e das demais escritas (issue de acompanhamento). +- Transliteração de grego, hangul, kana, árabe, hebraico, han e das demais escritas (issue #35). - Fundir linhas do índice ou contagens de nomes com `name_key` diferentes. -- Agrupar cognatos (`John`, `Johann`, `Juan`) pela P460, e qualquer comunidade ou corte por tamanho sobre ela (ADR 0010). -- `group_id` em `by_country/.csv` e um arquivo com as junções aplicadas. +- Agrupar cognatos (`John`, `Johann`, `Juan`) pela P460, e qualquer comunidade ou corte por tamanho sobre ela (ADR 0010; issue #37). +- `group_id` em `by_country/.csv` e um arquivo com as junções aplicadas (issue #36). - Restaurar acentos nos nomes das fontes que os removem. diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 27ff8a6..2da73c5 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -80,7 +80,7 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra ### Fechamento -- [ ] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. -- [ ] Números reais do agrupamento (R5.8) no pull request. -- [ ] Issues para o que ficou fora: transliteração de grego, hangul, kana e demais escritas; `group_id` em `by_country`; cognatos pela P460. +- [x] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. +- [x] Números reais do agrupamento (R5.8) no pull request. +- [x] Issues para o que ficou fora: transliteração de grego, hangul, kana e demais escritas (#35); `group_id` em `by_country` e junções aplicadas (#36); cognatos pela P460 (#37). - [ ] Pull request com `Closes #4`. From 88118b22443783219a685b40547d74c51b543f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:20:53 -0300 Subject: [PATCH 13/18] docs(variants): link spec 005 tasks and roadmap to PR #38 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/005-variantes/tasks.md | 2 +- docs/specs/ROADMAP.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index 2da73c5..e5d27d5 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -83,4 +83,4 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra - [x] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. - [x] Números reais do agrupamento (R5.8) no pull request. - [x] Issues para o que ficou fora: transliteração de grego, hangul, kana e demais escritas (#35); `group_id` em `by_country` e junções aplicadas (#36); cognatos pela P460 (#37). -- [ ] Pull request com `Closes #4`. +- [x] Pull request #38 com `Closes #4` e `Closes #31`. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index fda2433..6e07948 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -14,7 +14,7 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 3d | [`003d-statistics-canada`](003d-statistics-canada/spec.md) | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Mesclada | | 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Em PR | | 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Em PR | -| 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4) | Em PR | +| 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4), PR #38 | Em PR | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md), [0010 regras de agrupamento](../adr/0010-regras-de-agrupamento.md), [0011 correspondência sem acento](../adr/0011-correspondencia-sem-acento.md), [0012 transliteração para ASCII](../adr/0012-transliteracao-ascii.md). From 554bfc9c4112361e58afbc301829d4641bcf579a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:54:55 -0300 Subject: [PATCH 14/18] fix(variants): stop homograph labels and competing transliterations from bridging items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 now joins nodes through their Wikidata items: a label of items in different groups (장 of 张 and 蒋) stays alone. Phase 2 skips a transliteration whose destination is ambiguous or already belongs to another name in the source script (見 -> 현 of 玄), and an unaccented name already grouped elsewhere. Adds optional rule counters for the spec measurement. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/aggregate.py | 2 +- src/namescommons/variants.py | 114 ++++++++++++++++++++++++++++------ tests/test_variants.py | 103 ++++++++++++++++++++++++------ 3 files changed, 181 insertions(+), 38 deletions(-) diff --git a/src/namescommons/aggregate.py b/src/namescommons/aggregate.py index 0dc269c..3f244ec 100644 --- a/src/namescommons/aggregate.py +++ b/src/namescommons/aggregate.py @@ -227,7 +227,7 @@ def name_index( # Spec 005, R5: grupos sobre as chaves do índice (já em ordem), com o ascii das latinas. ids = group_ids( [r.name_key for r in indice], [r.ascii if r.script == "Latn" else "" for r in indice], - sem_acento, links, kind, + sem_acento, links, kind, [r.script for r in indice], ) for i, grupo in enumerate(ids): indice[i] = replace(indice[i], group_id=grupo) diff --git a/src/namescommons/variants.py b/src/namescommons/variants.py index 3c9c16e..65ece52 100644 --- a/src/namescommons/variants.py +++ b/src/namescommons/variants.py @@ -1,8 +1,10 @@ """Agrupamento de variantes (spec 005, R5; ADRs 0010 e 0011): group_id de cada nome do índice. Os nós são as chaves (name_key) de um kind com observação, numeradas pela posição na lista -ordenada. As junções são feitas em duas fases sobre inteiros, e `different_from` proíbe que dois -nós fiquem no mesmo grupo. +ordenada; os itens do Wikidata ganham posições depois delas. Na fase 1, os itens se juntam por +other_gender (sobrenomes) e cada nó entra no grupo dos seus itens só se todos eles já são um +grupo: um rótulo homógrafo de itens diferentes (장 de 张 e de 蒋) não faz ponte. `different_from` +proíbe que dois nós fiquem no mesmo grupo. """ from collections import defaultdict @@ -10,8 +12,8 @@ from namescommons.links import VariantLink -# R5.3: relações da fase 1 por kind. -FASE_1 = {"given": frozenset({"same_item"}), "family": frozenset({"same_item", "other_gender"})} +# R5.3: relação entre itens que os junta na fase 1, por kind. +ITENS_JUNTADOS_POR = {"given": frozenset(), "family": frozenset({"other_gender"})} class _Grupos: @@ -60,42 +62,109 @@ def group_ids( unaccented: list[bool], links: Iterable[VariantLink] | None, kind: str, + scripts: list[str] | None = None, + stats: dict[str, int] | None = None, ) -> list[str]: """group_id de cada chave (R5): o menor name_key do grupo. keys: chaves de um kind, ordenadas e sem repetição. ascii: forma ASCII das chaves latinas e "" nas demais. unaccented: a chave é atestada por fonte com strips_accents (ADR 0011). + scripts: escrita de cada chave (R5.4); sem ela, todas contam como da mesma escrita. + stats: se dado, recebe contagens de cada regra (para a medição da spec, R5.8). """ + conta: dict[str, int] = stats if stats is not None else {} + + def contar(regra: str) -> None: + conta[regra] = conta.get(regra, 0) + 1 + + n = len(keys) posicao = {k: i for i, k in enumerate(keys)} - fase_1 = FASE_1[kind] - juncoes_1: set[tuple[int, int]] = set() + item_pos: dict[str, int] = {} # QID -> posição n + j + itens_do_no: dict[int, set[int]] = defaultdict(set) + juncoes_itens: set[tuple[int, int]] = set() proibicoes: set[tuple[int, int]] = set() origens: dict[int, set[int]] = defaultdict(set) # destino de transliteração -> origens + + def item(qid: str) -> int: + return item_pos.setdefault(qid, n + len(item_pos)) + for v in links or (): # R5.1 e R5.2: só o kind pedido, nas duas pontas, e só nós com observação. if v.kind != kind or v.target_kind != kind: continue a, b = posicao.get(v.name_key), posicao.get(v.target_name_key) + if v.relation == "same_item" or v.relation in ITENS_JUNTADOS_POR[kind]: + # Os rótulos que estas relações ligam são os nomes dos itens na fase 1. + if a is not None: + itens_do_no[a].add(item(v.qid)) + if b is not None: + itens_do_no[b].add(item(v.target_qid)) + if v.relation in ITENS_JUNTADOS_POR[kind]: + juncoes_itens.add(_par(item(v.qid), item(v.target_qid))) if a is None or b is None or a == b: continue - if v.relation in fase_1: - juncoes_1.add(_par(a, b)) - elif v.relation == "different_from": + if v.relation == "different_from": proibicoes.add(_par(a, b)) elif v.relation == "transliteration": origens[b].add(a) - grupos = _Grupos(len(keys)) + grupos = _Grupos(n + len(item_pos)) for a, b in proibicoes: grupos.proibir(a, b) - for a, b in sorted(juncoes_1): + # Fase 1 (R5.3): itens juntados entre si, depois cada nó no grupo único dos seus itens. + for a, b in sorted(juncoes_itens): grupos.juntar(a, b) + juncoes_1 = [] + ambiguos: set[int] = set() # rótulos de itens de grupos diferentes: não fazem ponte em nenhuma fase + for no in sorted(itens_do_no): + raizes = {grupos.raiz(x) for x in itens_do_no[no]} + if len(raizes) == 1: + juncoes_1.append((no, min(itens_do_no[no]))) + else: + ambiguos.add(no) + contar("rotulo_ambiguo") + for no, it in juncoes_1: + if not grupos.juntar(no, it) and grupos.raiz(no) != grupos.raiz(it): + contar("pulada_por_different_from") + + # Para as concorrências da fase 2: tamanho de cada grupo da fase 1 em nós e as escritas dos + # nomes de cada grupo e de cada item (inclusive rótulos ambíguos, que não entraram no grupo). + def escrita(i: int) -> str: + return scripts[i] if scripts else "" + + tamanho: dict[int, int] = defaultdict(int) + escritas_do_grupo: dict[int, set[str]] = defaultdict(set) + for i in range(n): + r = grupos.raiz(i) + tamanho[r] += 1 + escritas_do_grupo[r].add(escrita(i)) + escritas_do_item: dict[int, set[str]] = defaultdict(set) + for no, itens in itens_do_no.items(): + for x in itens: + escritas_do_item[grupos.raiz(x)].add(escrita(no)) # R5.4: anexações calculadas sobre os grupos da fase 1, aplicadas depois em ordem fixa. juncoes_2: set[tuple[int, int]] = set() for destino, fontes in origens.items(): - if len({grupos.raiz(x) for x in fontes}) == 1: - juncoes_2.add(_par(destino, min(fontes))) + raizes = {grupos.raiz(x) for x in fontes} + if len(raizes) != 1 or destino in ambiguos: + contar("transliteracao_ambigua") + continue + (da_origem,) = raizes + # O destino concorre se já é nome de outro grupo ou rótulo de outro item que tem um nome + # na escrita da origem (현, rótulo de 玄, não é romanização de 見; Herman, só latino, é de Герман). + rivais: set[str] = set() + do_destino = grupos.raiz(destino) + if do_destino != da_origem and tamanho[do_destino] > 1: + rivais |= escritas_do_grupo[do_destino] + for x in itens_do_no.get(destino, ()): + if grupos.raiz(x) != da_origem: + rivais |= escritas_do_item[grupos.raiz(x)] + if rivais & {escrita(x) for x in fontes}: + contar("transliteracao_de_outro_nome") + continue + contar("transliteracao") + juncoes_2.add(_par(destino, min(fontes))) sem_acento = {ascii[i] for i, marca in enumerate(unaccented) if marca and ascii[i]} if sem_acento: por_ascii: dict[str, list[int]] = defaultdict(list) @@ -103,18 +172,27 @@ def group_ids( if forma in sem_acento: por_ascii[forma].append(i) for i, marca in enumerate(unaccented): - if not (marca and ascii[i]): + if not (marca and ascii[i]) or i in ambiguos: continue candidatos = [j for j in por_ascii[ascii[i]] if j != i] - if candidatos and len({grupos.raiz(j) for j in candidatos}) == 1: - juncoes_2.add(_par(i, min(candidatos))) + raizes = {grupos.raiz(j) for j in candidatos} + proprio = grupos.raiz(i) + # O próprio grupo, se já tem outros nomes e não é o dos candidatos, concorre. + if not candidatos: + continue + if len(raizes) != 1 or (proprio not in raizes and tamanho[proprio] > 1): + contar("sem_acento_ambiguo") + continue + contar("sem_acento") + juncoes_2.add(_par(i, min(candidatos))) for a, b in sorted(juncoes_2): - grupos.juntar(a, b) + if not grupos.juntar(a, b) and grupos.raiz(a) != grupos.raiz(b): + contar("pulada_por_different_from") # R5.7: a menor chave do grupo é a primeira na lista ordenada. menor: dict[int, int] = {} ids = [] - for i in range(len(keys)): + for i in range(n): r = grupos.raiz(i) primeira = menor.setdefault(r, i) ids.append(keys[primeira]) diff --git a/tests/test_variants.py b/tests/test_variants.py index cf5e956..f83c1ee 100644 --- a/tests/test_variants.py +++ b/tests/test_variants.py @@ -21,9 +21,10 @@ def _grupos(nomes, ligacoes=(), sem_acento=(), kind="given"): """nome -> group_id; nomes do índice com ascii só para os latinos, como name_index monta.""" chaves = sorted({name_key(n) for n in nomes}) formas = {name_key(n): n for n in nomes} - ascii_ = [ascii_form(formas[k]) if detect_script(formas[k]) == "Latn" else "" for k in chaves] + escritas = [detect_script(formas[k]) for k in chaves] + ascii_ = [ascii_form(formas[k]) if e == "Latn" else "" for k, e in zip(chaves, escritas)] marcas = [formas[k] in sem_acento for k in chaves] - ids = group_ids(chaves, ascii_, marcas, list(ligacoes), kind) + ids = group_ids(chaves, ascii_, marcas, list(ligacoes), kind, escritas) return {formas[k]: g for k, g in zip(chaves, ids)} @@ -101,17 +102,17 @@ def test_sem_acento_nao_junta_outra_escrita(): def test_different_from_separa(): - """A e B são different_from; C é rótulo do mesmo item que A e que B: só uma junção entra.""" + """Q1 (Ivanov, Иванов) e Q2 (Ivanova) são o mesmo sobrenome, mas Иванов é different_from Ivanova.""" ligacoes = [ - _l("same_item", "Ana", "Anna", qid="Q1"), - _l("same_item", "Anna", "Anne", qid="Q3"), - _l("different_from", "Ana", "Anne", qid="Q1", target_qid="Q3"), + _l("same_item", "Ivanov", "Иванов", kind="family", qid="Q1"), + _l("other_gender", "Ivanov", "Ivanova", kind="family", qid="Q1", target_qid="Q2"), + _l("different_from", "Иванов", "Ivanova", kind="family", qid="Q1", target_qid="Q2"), ] - g = _grupos(["Ana", "Anna", "Anne"], ligacoes) - assert g["Ana"] != g["Anne"] - # Ordem fixa: (ana, anna) entra antes de (anna, anne), que é pulada. - assert g["Ana"] == g["Anna"] == "ana" - assert g["Anne"] == "anne" + g = _grupos(["Ivanov", "Иванов", "Ivanova"], ligacoes, kind="family") + assert g["Иванов"] != g["Ivanova"] + # Ordem fixa: ivanov e ivanova entram antes de иванов, que é pulado. + assert g["Ivanov"] == g["Ivanova"] == "ivanov" + assert g["Иванов"] == "иванов" def test_different_from_separa_tambem_na_fase_2(): @@ -145,24 +146,28 @@ def test_juncao_pela_chave_e_nao_pelo_texto(): def test_resultado_nao_depende_da_ordem(): - nomes = ["Ana", "Anna", "Anne", "Ann", "Annie", "Marie-Eve", "Marie-Ève"] + nomes = ["Ana", "Anna", "Anne", "Ann", "Annie", "Marie-Eve", "Marie-Ève", "Анна"] ligacoes = [ _l("same_item", "Ana", "Anna", qid="Q1"), - _l("same_item", "Anna", "Anne", qid="Q3"), - _l("same_item", "Ann", "Annie", qid="Q4"), - _l("same_item", "Annie", "Anne", qid="Q4"), + _l("same_item", "Anne", "Annie", qid="Q3"), + _l("same_item", "Ann", "Annie", qid="Q3"), _l("different_from", "Ana", "Anne", qid="Q1", target_qid="Q3"), - _l("different_from", "Ann", "Ana", qid="Q4", target_qid="Q1"), + _l("different_from", "Ann", "Ana", qid="Q3", target_qid="Q1"), + _l("same_item", "Marie-Ève", "Анна", qid="Q5"), ] - esperado = _grupos(nomes, ligacoes, sem_acento={"Marie-Eve"}) + # Transliteração e sem acento têm direção própria: entram embaralhadas, sem inverter. + dirigidas = [_l("transliteration", "Анна", "Anna", qid="Q6")] + esperado = _grupos(nomes, ligacoes + dirigidas, sem_acento={"Marie-Eve"}) for semente in range(20): embaralhadas = ligacoes[:] random.Random(semente).shuffle(embaralhadas) invertidas = [ _l(v.relation, v.target_name, v.name, qid=v.target_qid, target_qid=v.qid) for v in embaralhadas ] - assert _grupos(nomes, embaralhadas, sem_acento={"Marie-Eve"}) == esperado - assert _grupos(nomes, invertidas, sem_acento={"Marie-Eve"}) == esperado + todas = embaralhadas + dirigidas + random.Random(semente).shuffle(todas) + assert _grupos(nomes, todas, sem_acento={"Marie-Eve"}) == esperado + assert _grupos(nomes, invertidas + dirigidas, sem_acento={"Marie-Eve"}) == esperado def test_group_id_e_a_menor_chave(): @@ -172,3 +177,63 @@ def test_group_id_e_a_menor_chave(): def test_sem_ligacoes(): assert group_ids(["a", "b"], ["a", "b"], [False, False], None, "given") == ["a", "b"] + + +# Revisão do PR #38: rótulo homógrafo de itens diferentes não faz ponte. +def test_rotulo_de_dois_itens_nao_junta_os_itens(): + """장 é rótulo de 张 (Q1) e de 蒋 (Q2): fica sozinho, e 张 e 蒋 continuam separados.""" + ligacoes = [_l("same_item", "张", "장", kind="family", qid="Q1"), + _l("same_item", "蒋", "장", kind="family", qid="Q2")] + g = _grupos(["张", "蒋", "장"], ligacoes, kind="family") + assert len(set(g.values())) == 3 + + +def test_rotulo_de_dois_itens_do_mesmo_grupo_junta(): + """Herman de Q1 e de Q2, com Q1 e Q2 ligados por other_gender: os itens já são um grupo.""" + ligacoes = [_l("same_item", "Herman", "Hermanová", kind="family", qid="Q1"), + _l("same_item", "Herman", "Хэрман", kind="family", qid="Q2"), + _l("other_gender", "Herman", "Hermanová", kind="family", qid="Q2", target_qid="Q1")] + g = _grupos(["Herman", "Hermanová", "Хэрман"], ligacoes, kind="family") + assert len(set(g.values())) == 1 + + +def test_transliteracao_para_rotulo_de_outro_nome_nao_junta(): + """見 tem a transliteração 현, que já é rótulo de 玄: 見 não entra no grupo de 玄.""" + ligacoes = [_l("same_item", "玄", "현", kind="family", qid="Q1"), + _l("transliteration", "見", "현", kind="family", qid="Q2")] + g = _grupos(["玄", "현", "見"], ligacoes, kind="family") + assert g["玄"] == g["현"] != g["見"] + + +def test_transliteracao_para_grupo_de_outra_escrita_junta(): + """Герман -> Herman, e Herman já está com Hermanová (other_gender): junção legítima.""" + ligacoes = [_l("other_gender", "Hermanová", "Herman", kind="family", qid="Q2", target_qid="Q3"), + _l("other_gender", "Herman", "Hermanová", kind="family", qid="Q3", target_qid="Q2"), + _l("transliteration", "Герман", "Herman", kind="family", qid="Q1")] + g = _grupos(["Herman", "Hermanová", "Герман"], ligacoes, kind="family") + assert g["Herman"] == g["Hermanová"] == g["Герман"] == "herman" + + +def test_sem_acento_ja_em_outro_grupo_nao_junta(): + """Jose já está com Хосе (mesmo item); José está em outro grupo: concorrem, nada junta.""" + ligacoes = [_l("same_item", "Jose", "Хосе", qid="Q1"), _l("same_item", "José", "Josè", qid="Q2")] + g = _grupos(["Jose", "Хосе", "José", "Josè"], ligacoes, sem_acento={"Jose"}) + assert g["Jose"] != g["José"] + + +def test_rotulo_ambiguo_nao_recebe_transliteracao(): + """현 é rótulo de 玄 (Q1) e de 見 (Q2); a transliteração 見 -> 현 não o tira dessa ambiguidade.""" + ligacoes = [_l("same_item", "玄", "현", kind="family", qid="Q1"), + _l("same_item", "見", "현", kind="family", qid="Q2"), + _l("transliteration", "見", "현", kind="family", qid="Q2")] + g = _grupos(["玄", "見", "현"], ligacoes, kind="family") + assert len(set(g.values())) == 3 + + +def test_transliteracao_para_rotulo_de_outro_item_com_rotulo_ambiguo_nao_junta(): + """玄 é ambíguo (rótulo de Q1 e Q3), e 현 fica sozinho no grupo; ainda assim é rótulo de Q1, de escrita han.""" + ligacoes = [_l("same_item", "현", "玄", kind="family", qid="Q1"), + _l("same_item", "玄", "Hyeon", kind="family", qid="Q3"), + _l("transliteration", "見", "현", kind="family", qid="Q2")] + g = _grupos(["玄", "見", "현", "Hyeon"], ligacoes, kind="family") + assert g["見"] != g["현"] From 0aa499c3aee340d43cb03f2b6b6f2698d9b63723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:54:56 -0300 Subject: [PATCH 15/18] fix(normalize): casefold after NFKD, follow ISO Hrkt and keep final particles capitalised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ascii_form no longer returns capitals for ™, ℳ, ℡ or math letters, which failed validate and aborted the source. Hiragana with katakana is Hrkt; half-width ー is ignored; a particle in the last word stays capitalised (John Van). Explains the 1.2.0 bump in schema.py. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/normalize.py | 15 ++++++++++----- src/namescommons/schema.py | 4 +++- tests/test_normalize.py | 24 +++++++++++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/namescommons/normalize.py b/src/namescommons/normalize.py index 88f1cb7..b175610 100644 --- a/src/namescommons/normalize.py +++ b/src/namescommons/normalize.py @@ -24,8 +24,9 @@ "MEETEI MAYEK": "Mtei", "SYLOTI NAGRI": "Sylo", "EGYPTIAN HIEROGLYPH": "Egyp", } # R1.2: 々 é han; as marcas de som longo e de iteração do kana herdam a escrita vizinha. +# 々 e 〆 têm Script=Han no Unicode, mas o nome deles não começa por CJK. _HAN_EXTRA = frozenset("々〆") -_SEM_ESCRITA = frozenset("ーゝゞヽヾ") +_SEM_ESCRITA = frozenset("ーーゝゞヽヾ") _JAPONES = frozenset({"Hani", "Hira", "Kana"}) # Separadores que iniciam uma nova palavra na capitalização. @@ -89,7 +90,8 @@ def _particulas(nome: str) -> str: for i in range(1, len(palavras)): minuscula = palavras[i].lower() anterior = palavras[i - 1].lower() - if minuscula in _PARTICULAS or (minuscula in _ARTIGOS and anterior in _PARTICULAS): + tem_seguinte = i + 1 < len(palavras) # partícula no fim do nome é o próprio nome (John Van) + if tem_seguinte and (minuscula in _PARTICULAS or (minuscula in _ARTIGOS and anterior in _PARTICULAS)): palavras[i] = minuscula elif minuscula[:2] in _ELISOES and len(minuscula) > 2: palavras[i] = minuscula[:2] + palavras[i][2:] @@ -114,7 +116,7 @@ def _escrita_da_letra(c: str) -> str: def detect_script(name: str) -> str: """Código ISO 15924 predominante entre as letras; Zyyy sem letras, Zzzz se desconhecido. - Spec 005, R1: Jpan para han com kana, Kore para hangul com han. + Spec 005, R1: Jpan para han com kana, Hrkt para hiragana com katakana, Kore para hangul com han. """ contagem: dict[str, int] = {} for c in name: @@ -126,7 +128,8 @@ def detect_script(name: str) -> str: return "Zyyy" escritas = set(contagem) if len(escritas) > 1 and escritas <= _JAPONES and escritas & {"Hira", "Kana"}: - return "Jpan" + # ISO 15924: Jpan é han com kana; Hrkt, hiragana com katakana sem han. + return "Jpan" if "Hani" in escritas else "Hrkt" if escritas == {"Hang", "Hani"}: return "Kore" return max(sorted(contagem), key=lambda codigo: contagem[codigo]) @@ -147,7 +150,9 @@ def ascii_form(name: str, script: str | None = None) -> str: `script` não decide mais nada; fica na assinatura por compatibilidade. """ partes = [] - for c in unicodedata.normalize("NFKD", name.casefold().translate(_PONTUACAO)): + # A NFKD pode criar maiúsculas (™ vira TM, ℳ vira M): casefold de novo depois dela. + base = unicodedata.normalize("NFKD", name.casefold().translate(_PONTUACAO)) + for c in unicodedata.normalize("NFKD", base.casefold()): t = _ascii_do_caractere(c) if t is None: return "" diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index 5371f28..01dcb4b 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -8,7 +8,9 @@ from namescommons.sources import COUNT_TYPES, Source, is_country # 1.1.0: variant_links.parquet na release (spec 004, R4.7). -# 1.2.0: group_id preenchido, ascii em cirílico, Jpan e Kore, partículas (spec 005, R8). +# 1.2.0 (spec 005, R8): group_id preenchido; ascii em cirílico e possivelmente vazio em latim; +# Jpan, Hrkt e Kore; partículas e nova forma de exibição. ingest e build de uma release devem usar +# a mesma versão: validate confere script e ascii gravados pelo ingest. SCHEMA_VERSION = "1.2.0" KINDS = ("given", "family") GENDERS = ("F", "M") diff --git a/tests/test_normalize.py b/tests/test_normalize.py index edc0733..1d35c0d 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -75,7 +75,9 @@ def test_georgiano_nao_recebe_capital_errada(): ("Anna", "Latn"), # latim de largura total (R1.1) ("アイ", "Kana"), # katakana de meia largura (R1.1) ("さくら子", "Jpan"), # kanji e hiragana (R1.3) - ("ゆかりユカリ", "Jpan"), # hiragana e katakana (R1.3) + ("ゆかりユカリ", "Hrkt"), # hiragana e katakana sem han: Hrkt da ISO 15924 (R1.3) + ("ユーリ", "Kana"), # ー de meia largura também não conta (R1.2) + ("〆子", "Hani"), # 〆 tem Script=Han no Unicode (R1.2) ("ゆかり", "Hira"), ("김金", "Kore"), # hangul e hanja (R1.3) ("ଦାସ", "Orya"), # R1.4 @@ -167,3 +169,23 @@ def test_particulas_em_caixa_uniforme(bruto, esperado): def test_caixa_publicada_nao_muda_particulas(): assert normalize_name("Da Silva", uniform_case=False) == "Da Silva" assert normalize_name("Joana D'Arc", uniform_case=False) == "Joana D'Arc" + + +# Revisão do PR #38: NFKD pode produzir maiúsculas; ascii_form nunca devolve maiúscula. +@pytest.mark.parametrize("nome, esperado", [ + ("Ali™", "alitm"), + ("ℳaria", "maria"), + ("℡", "tel"), + ("𝓜aria", "maria"), +]) +def test_forma_ascii_sem_maiusculas_depois_da_nfkd(nome, esperado): + assert ascii_form(nome, detect_script(nome)) == esperado + + +@pytest.mark.parametrize("bruto, esperado", [ + ("JOHN VAN", "John Van"), # partícula na última palavra não é partícula + ("MARIA DA", "Maria Da"), + ("ANA DE LA", "Ana de La"), +]) +def test_particula_no_fim_do_nome_fica_capitalizada(bruto, esperado): + assert normalize_name(bruto, uniform_case=True) == esperado From 276792d67cc9f86e1bafe4d0449d7bb84980eb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 15:54:56 -0300 Subject: [PATCH 16/18] docs(variants): update spec 005, ADR 0010 and roadmap after PR #38 review Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-regras-de-agrupamento.md | 8 ++++---- docs/specs/001-esquema/spec.md | 2 +- docs/specs/005-variantes/spec.md | 28 +++++++++++++------------- docs/specs/005-variantes/tasks.md | 8 ++++++++ docs/specs/ROADMAP.md | 4 ++-- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/adr/0010-regras-de-agrupamento.md b/docs/adr/0010-regras-de-agrupamento.md index b9ef8d5..0b78edd 100644 --- a/docs/adr/0010-regras-de-agrupamento.md +++ b/docs/adr/0010-regras-de-agrupamento.md @@ -18,8 +18,8 @@ O fecho transitivo das ligações publicadas pelo Wikidata não serve como grupo - Os nós são os pares (`kind`, `name_key`) com observação. As ligações se juntam aos nós pela chave, nunca pelo texto do nome, e são tratadas como não dirigidas. Ligação com ponta sem observação é ignorada. - Prenome e sobrenome nunca se juntam; `group_id` é único dentro de cada `kind`. -- **Fase 1, mesmo nome:** `same_item` e, só em sobrenomes, `other_gender` (P5278: `Иванов` e `Иванова` são o mesmo sobrenome flexionado). Em prenomes, `other_gender` (P1560: `Paulo` e `Paula`) não junta. -- **Fase 2, anexação inequívoca,** calculada sobre os grupos da fase 1: a transliteração junta o destino ao grupo de origem só se todas as transliterações que chegam ao destino vêm do mesmo grupo; a regra sem acento (ADR 0011) junta um nome sem acento aos nomes com o mesmo `ascii` só se todos eles estão no mesmo grupo. +- **Fase 1, mesmo item:** os itens se juntam só por `other_gender` em sobrenomes (P5278: `Иванов` e `Иванова` são o mesmo sobrenome flexionado); em prenomes, `other_gender` (P1560: `Paulo` e `Paula`) não junta. Cada nó entra no grupo dos itens de que é rótulo (por `same_item` ou, em sobrenomes, `other_gender`) só se todos esses itens já são um grupo. Um rótulo homógrafo de itens diferentes fica sozinho: na primeira versão desta regra, que juntava por `same_item` direto, `장` ligava `张` e `蒋`, `진` ligava `陈`, `秦` e `真`, e 61 grupos de prenomes e 162 de sobrenomes misturavam itens diferentes (revisão do PR #38). +- **Fase 2, anexação inequívoca,** calculada sobre os grupos da fase 1: a transliteração junta o destino ao grupo de origem só se todas as transliterações que chegam ao destino vêm do mesmo grupo, se o destino não é rótulo homógrafo e se o destino não é já nome de outro grupo ou rótulo de outro item com um nome na escrita da origem (`見` -> `현` não entra no grupo de `玄`, de que `현` é rótulo; `Герман` -> `Herman` entra no grupo latino de `Herman` e `Hermanová`). A regra sem acento (ADR 0011) junta um nome sem acento aos nomes com o mesmo `ascii` só se todos eles estão no mesmo grupo e se o próprio grupo do nome, quando tem outros nomes, é esse. - `said_to_be_the_same` (P460) não junta. Ela continua em `variant_links` para quem quiser cognatos. - `different_from` (P1889) proíbe que dois nós fiquem no mesmo grupo. As junções são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode); a que violaria a proibição é pulada. - `group_id` é o menor `name_key` do grupo em ordem de código Unicode; nome sem variante é seu próprio grupo. @@ -27,8 +27,8 @@ O fecho transitivo das ligações publicadas pelo Wikidata não serve como grupo ## Consequências -- Os grupos são pequenos (o maior, na simulação sobre o lote real, tem 31 nomes) e cada junção tem uma causa nomeável: mesmo item, mesmo sobrenome no outro gênero, transliteração não ambígua ou fonte que remove acentos. +- Os grupos são pequenos (no lote real, o maior tem 21 nomes, todos rótulos do mesmo item) e cada junção tem uma causa nomeável: mesmo item, mesmo sobrenome no outro gênero, transliteração não ambígua ou fonte que remove acentos. Medido: 358 rótulos homógrafos sem ponte, 3.240 transliterações anexadas (898 ambíguas e 25 concorrentes de fora), 14.134 nomes sem acento anexados (3.103 ambíguos de fora) e 248 junções puladas por `different_from`. - Cognatos (`John`, `Johann`, `Juan`) e grafias alternativas ligadas só pela P460 (`Ivanov` e `Iwanow`) ficam em grupos separados. Quem precisar delas usa `variant_links`. -- Romanizações ambíguas e nomes sem acento ambíguos (`Ines` entre `Inès` e `Inés`) ficam fora de qualquer grupo com variantes, o que perde junções corretas em troca de não criar erradas. +- Romanizações ambíguas, rótulos homógrafos e nomes sem acento ambíguos (`Ines` entre `Inès` e `Inés`) ficam fora de qualquer grupo com variantes, o que perde junções corretas em troca de não criar erradas. - `group_id` muda quando entra no grupo um nome com `name_key` menor; ele identifica o grupo dentro de uma release, não entre releases. - Uma relação nova do Wikidata, ou outra fonte com `links`, só passa a juntar nomes com mudança desta decisão. diff --git a/docs/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index ff77f2c..2b16485 100644 --- a/docs/specs/001-esquema/spec.md +++ b/docs/specs/001-esquema/spec.md @@ -60,7 +60,7 @@ Nota (spec 004, R5): a exigência de exatamente uma `primary` vale só para `bir | `group_id` | Grupo de variantes, preenchido pela spec 005. Vazio até lá | | `sources` | Lista ordenada de `source_id` que atestam o nome | -Nota (spec 005): `group_id` é preenchido pela [spec 005](../005-variantes/spec.md) (R5), a forma de exibição (`name`) segue a spec 005 (R4) e `ascii` segue o [ADR 0012](../../adr/0012-transliteracao-ascii.md). +Nota (spec 005): `group_id` é preenchido pela [spec 005](../005-variantes/spec.md) (R5), a forma de exibição (`name`) segue a spec 005 (R4) e `ascii` segue o [ADR 0012](../../adr/0012-transliteracao-ascii.md). `group_id` é o menor `name_key` do grupo e identifica o grupo dentro de uma release: ele pode mudar entre releases quando entra no grupo um nome com chave menor ou quando as ligações mudam. **R3.3 `by_country/.csv`.** Uma linha por (`name_key`, `kind`) do país, com `name`, `ascii`, `script`, `count`, `count_type`, `rank`, `female_share`, `gender_basis`, `first_year`, `last_year`. Agregação: diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index ec46bcc..cb28f87 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -17,15 +17,15 @@ As fontes oficiais da Statistics Canada, do INE e da SSA publicam os nomes sem a `detect_script` continua devolvendo o código ISO 15924 predominante entre as letras (spec 001, R1), com estas correções: - **R1.1** Letras de largura total ou meia largura (`A`, `ア`) contam pela escrita da letra (`Latn`, `Kana`). -- **R1.2** `々` (marca de iteração) conta como `Hani`. `ー` (marca de som longo) e `ゝ`, `ゞ`, `ヽ`, `ヾ` não contam: herdam a escrita das letras vizinhas. -- **R1.3** Nome cujas letras são só de `Hani`, `Hira` e `Kana`, com pelo menos uma de `Hira` ou `Kana` e pelo menos duas dessas três, é `Jpan` (`さくら子`, `ゆかり`/`ユカリ` misturados). Nome com `Hang` e `Hani` e nenhuma outra é `Kore`. +- **R1.2** `々` (marca de iteração) e `〆` contam como `Hani`, como a propriedade Script do Unicode os classifica. `ー` e `ー` (marca de som longo, larga e de meia largura) e `ゝ`, `ゞ`, `ヽ`, `ヾ` não contam: herdam a escrita das letras vizinhas. +- **R1.3** Seguindo a ISO 15924: nome com `Hani` e `Hira` ou `Kana`, e nenhuma outra escrita, é `Jpan` (`さくら子`); nome com `Hira` e `Kana`, sem han, é `Hrkt` (`ゆかりユカリ`). Nome com `Hang` e `Hani` e nenhuma outra é `Kore`. - **R1.4** A tabela de escritas cobre também as que apareceram como `Zzzz` nos dados reais (medição de 2026-10-12 sobre o lote completo, 100 nomes): oriá (`Orya`), batak (`Batk`), ol chiki (`Olck`), brahmi (`Brah`), fenício (`Phnx`), siríaco (`Syrc`), tifinagh (`Tfng`), gótico (`Goth`), cherokee (`Cher`), silabário canadense (`Cans`), mandaico (`Mand`), copta (`Copt`), rúnico (`Runr`), warang citi (`Wara`), meetei mayek (`Mtei`), syloti nagri (`Sylo`), hieróglifos egípcios (`Egyp`), thaana (`Thaa`), n'ko (`Nkoo`) e vai (`Vaii`). Letra de escrita fora da tabela continua `Zzzz`. ### R2. Forma ASCII (`ascii`) `ascii` é a transliteração do nome para ASCII em minúsculas, caractere a caractere, pelo método do ADR 0012: -- **R2.1 Latim.** Decomposição NFKD (ligaduras e dígrafos de compatibilidade como `Dž`, `fi` e letras de largura total viram letras simples), remoção das marcas combinantes e a tabela de letras sem decomposição: `ß` `ss`, `ẞ` `ss`, `ø` `o`, `æ` `ae`, `œ` `oe`, `đ` `d`, `ð` `d`, `ł` `l`, `þ` `th`, `ı` `i`, `ħ` `h`, `ŧ` `t`, `ŋ` `n`, `ə` `e`, `ɛ` `e`, `ɔ` `o`, `ɣ` `g`, `ʙ` `b`. Os valores seguem a tabela A da seção 6 do ICAO Doc 9303 (parte 3, 8.ª edição, 2021) onde ela tem a letra, com a forma de uma letra só onde ela dá alternativa (`Ä` "AE or A" vira `a`), e as letras de alfabetos africanos ausentes do ICAO (`ə`, `ɛ`, `ɔ`, `ɣ`) usam a vogal ou consoante latina de mesma base. +- **R2.1 Latim.** Decomposição NFKD, seguida de nova conversão para minúsculas, porque a NFKD cria maiúsculas (`™` vira `TM`, `ℳ` e `𝓜` viram `M`, `℡` vira `TEL`) (ligaduras e dígrafos de compatibilidade como `Dž`, `fi` e letras de largura total viram letras simples), remoção das marcas combinantes e a tabela de letras sem decomposição: `ß` `ss`, `ẞ` `ss`, `ø` `o`, `æ` `ae`, `œ` `oe`, `đ` `d`, `ð` `d`, `ł` `l`, `þ` `th`, `ı` `i`, `ħ` `h`, `ŧ` `t`, `ŋ` `n`, `ə` `e`, `ɛ` `e`, `ɔ` `o`, `ɣ` `g`, `ʙ` `b`. Os valores seguem a tabela A da seção 6 do ICAO Doc 9303 (parte 3, 8.ª edição, 2021) onde ela tem a letra, com a forma de uma letra só onde ela dá alternativa (`Ä` "AE or A" vira `a`), e as letras de alfabetos africanos ausentes do ICAO (`ə`, `ɛ`, `ɔ`, `ɣ`) usam a vogal ou consoante latina de mesma base. - **R2.2 Cirílico.** A tabela B da mesma seção do ICAO Doc 9303, na forma padrão de cada letra (sem as exceções por língua, porque o dataset não sabe a língua do nome): `ж` `zh`, `х` `kh`, `ц` `ts`, `ч` `ch`, `ш` `sh`, `щ` `shch`, `ъ` `ie`, `ы` `y`, `э` `e`, `ю` `iu`, `я` `ia`, `ё` `e`, `й` `i`, `є` `ie`, `ї` `i`, `ђ` `d`, `ѕ` `dz`, `ј` `j`, `љ` `lj`, `њ` `nj`, `ќ` `k`, `ў` `u`, `џ` `dz`, `ґ` `g`, `ғ` `g`, `һ` `c`, `ѫ` `u`, `ѵ` `y` e as letras restantes do russo como na tabela. `ь` não tem transliteração e é omitido. `ћ`, ausente da tabela, vira `c`, como `ć` do alfabeto latino sérvio que lhe corresponde. - **R2.3 Pontuação do nome.** Caracteres ASCII ficam como estão (em minúsculas), exceto a crase, que vira `'`. Apóstrofos tipográficos e modificadores (`’`, `‘`, `ʼ`, `ʻ`, `ʹ`, `ʾ`, `ʿ`, `` ` ``, `´`) viram `'`. Hífens e travessões Unicode (`‐`, `‑`, `‒`, `–`) viram `-`. O ponto médio (`·`, do catalão `l·l`) e caracteres de formatação (categoria `Cf`, como o ZWJ) são omitidos. - **R2.4 Sem transliteração.** Se algum caractere não tem transliteração por R2.1 a R2.3 (letra de outra escrita, letra latina fora da tabela), `ascii` fica vazio: nunca se emite uma forma parcial. Grego, hangul, kana e as demais escritas ficam vazias (issue #35). Medido no lote real: 8 nomes latinos ficam vazios (letra grega ou tailandesa no meio do nome, `¶`), e 332 nomes cirílicos também, por letras do cazaque, tártaro e tadjique fora da tabela do ICAO (`ә`, `қ`, `ү`, `ҳ`). @@ -35,8 +35,8 @@ As fontes oficiais da Statistics Canada, do INE e da SSA publicam os nomes sem a Quando a fonte tem `uniform_case: true` (spec 002, R1.3), depois da capitalização por palavra: -- **R3.1** Palavra que não é a primeira do nome, separada por espaço, e que é uma das partículas `da`, `das`, `de`, `del`, `della`, `der`, `den`, `des`, `di`, `do`, `dos`, `du`, `van`, `von`, `y` vai para minúsculas: `Maria del Carmen`, `Van der Berg`, `Alves da Silva`, `Gabriel y Galan`. -- **R3.2** Os artigos `la`, `las`, `le`, `los` vão para minúsculas só logo depois de uma partícula de R3.1: `Abel de la Cruz`, `De la de las Heras`. Isolados ficam capitalizados, porque também são nomes (`Jia Le`, medido no INE). +- **R3.1** Palavra que não é a primeira do nome, separada por espaço, e que é uma das partículas `da`, `das`, `de`, `del`, `della`, `der`, `den`, `des`, `di`, `do`, `dos`, `du`, `van`, `von`, `y` vai para minúsculas quando há palavra depois dela: `Maria del Carmen`, `Van der Berg`, `Alves da Silva`, `Gabriel y Galan`. +- **R3.2** Os artigos `la`, `las`, `le`, `los` vão para minúsculas só logo depois de uma partícula de R3.1: `Abel de la Cruz`, `De la de las Heras`. Isolados ficam capitalizados, porque também são nomes (`Jia Le`, medido no INE). Na última palavra, partícula e artigo também ficam capitalizados: ali eles são o próprio nome (`John Van`, `Maria Da`). - **R3.3** Palavra que não é a primeira e começa por `d'` ou `l'` fica com o prefixo em minúsculas e a letra seguinte em maiúscula: `Joana d'Arc`, `Francesc d'Assis`. Na primeira palavra o prefixo continua maiúsculo (`D'Arcy`). - **R3.4** Partes ligadas por hífen não mudam (`Marie-Ève`, `Jean-De-Dieu` continua como a capitalização por palavra o deixa). - **R3.5** Fontes com `uniform_case: false` continuam com a caixa publicada (`Da Silva` do Wikidata não muda). @@ -54,16 +54,16 @@ A forma de exibição deixa de somar contagens de tipos, edições ou fontes que Cada linha de `given_names` e `family_names` recebe `group_id`. O agrupamento é determinístico, por `kind`, sobre o grafo cujos nós são os pares (`kind`, `name_key`) com observação, conforme o ADR 0010: -- **R5.1 Junção pela chave.** Uma ligação de `variant_links` se liga às observações só por (`kind`, `name_key`) e (`target_kind`, `target_name_key`), nunca pelo texto de `name`: a ligação pode trazer uma forma que difere em caixa da forma exibida (`McKenzie` na ligação, `Mckenzie` no índice). Ligação com uma ponta sem observação (por exemplo, transliteração sem fonte que a ateste, 30.649 das 36.909 medidas) é ignorada. A direção publicada não importa: toda ligação usada é tratada como não dirigida. +- **R5.1 Junção pela chave.** Uma ligação de `variant_links` se liga às observações só por (`kind`, `name_key`) e (`target_kind`, `target_name_key`), nunca pelo texto de `name`: a ligação pode trazer uma forma que difere em caixa da forma exibida (`McKenzie` na ligação, `Mckenzie` no índice). Ligação com uma ponta sem observação (por exemplo, transliteração sem fonte que a ateste, 30.649 das 36.909 medidas) é ignorada. Nas relações entre rótulos (`same_item`, `other_gender`, `different_from`) a direção publicada não importa. `transliteration` e a regra sem acento têm direção: o destino da transliteração (um texto) e o nome sem acento são os que se anexam ao grupo do outro lado. - **R5.2 Tipos diferentes nunca se juntam.** Ligação com `kind` diferente de `target_kind` (`family_name_identical`, e as poucas `said_to_be_the_same`, `other_gender` e `different_from` entre prenome e sobrenome) não junta nada. `group_id` é único dentro de cada `kind`. -- **R5.3 Fase 1, mesmo nome.** Juntam: `same_item` (rótulos nativos do mesmo item) e, só em sobrenomes, `other_gender` (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. +- **R5.3 Fase 1, mesmo item.** Os itens do Wikidata são juntados entre si só por `other_gender` em sobrenomes (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. Cada nó é rótulo dos itens em que aparece em `same_item` ou, em sobrenomes, em `other_gender`, e entra no grupo desses itens só se todos eles já formam um grupo. Um rótulo homógrafo de itens diferentes (`장`, rótulo coreano de `张` e de `蒋`; `진`, de `陈`, `秦` e `真`) fica sozinho e não faz ponte entre eles. - **R5.4 Fase 2, anexação inequívoca.** Calculada sobre os grupos da fase 1: - - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1. Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. - - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós latinos do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1 (um nó cirílico com o mesmo `ascii`, como `Иван` e `Ivan`, não entra: isso é transliteração, não acento). `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. + - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1, se ele não é rótulo homógrafo (R5.3) e se não concorre com outro nome: quando o destino já está num grupo com outros nomes, ou é rótulo de outro item, e esse grupo ou item tem um nome na escrita da origem, o destino pertence àquele nome e a transliteração não junta (`현` é rótulo de `玄`; a transliteração `見` -> `현` não põe `見` no grupo de `玄`). Se o grupo do destino só tem outras escritas, a junção é legítima (`Герман` -> `Herman`, que já está com `Hermanová`). Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. + - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós latinos do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1, se ele não é rótulo homógrafo e se o grupo dele, quando tem outros nomes, é esse mesmo (um nó cirílico com o mesmo `ascii`, como `Иван` e `Ivan`, não entra: isso é transliteração, não acento). `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. - **R5.5 Não juntam.** `said_to_be_the_same` (P460) não junta: o fecho transitivo dessas ligações forma um componente de 14.039 nomes mesmo com os cortes de R5.6 (medido nos dados reais, ADR 0010). Nomes que só diferem em acento, sem fonte que remove acentos, também não juntam. -- **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções das fases 1 e 2 são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. +- **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções são aplicadas em ordem fixa (fase 1: junções entre itens, depois cada nó na ordem do `name_key`; fase 2: pares de `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. - **R5.7 Identificador.** `group_id` é o menor `name_key` do grupo em ordem de código Unicode (`marie-eve` para `Marie-Eve` e `Marie-Ève`). Nome sem variante forma um grupo sozinho, e seu `group_id` é o próprio `name_key`. Sem `variant_links` (`build` sem `--links`), só a regra sem acento junta nomes. -- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata, 1.008.182 nós), a simulação destas regras anexa 3.347 transliterações (816 ambíguas ficam de fora) e 14.394 nomes sem acento (2.883 ambíguos ficam de fora), pula 292 junções por `different_from` e forma 35.759 grupos com mais de um nome, o maior com 31 (um sobrenome coreano escrito com hanjas diferentes no mesmo item do Wikidata). Com a implementação, no mesmo lote: 8.270 grupos com variantes em prenomes (17.943 nomes; maior com 31) e 27.459 em sobrenomes (65.384 nomes; maior com 17). +- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata; 278.486 prenomes e 729.696 sobrenomes no índice): 358 rótulos homógrafos ficam sem ponte; 3.240 transliterações são anexadas, 898 ficam de fora por ambíguas e 25 por concorrência com outro nome; 14.134 nomes sem acento são anexados e 3.103 ficam de fora por ambíguos; 248 junções são puladas por `different_from`. Resultado: 8.248 grupos com variantes em prenomes (17.712 nomes; o maior com 21, um sobrenome coreano escrito com hanjas diferentes no mesmo item) e 27.387 em sobrenomes (64.679 nomes; o maior com 9). Grupos cujos rótulos de `same_item` vêm de itens diferentes (sem contar os juntados por `other_gender`): de 61 prenomes e 162 sobrenomes, com a primeira versão desta regra, para 1 e 3 (romanizações latinas que também são sobrenome de outro item, como `Lik`). ### R6. Fontes que removem acentos (`strips_accents`) @@ -86,7 +86,7 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ### R8. Versão -`group_id` preenchido, `ascii` em cirílico, `Jpan` e `Kore` em `script` e a nova capitalização mudam valores das saídas sem remover nem renomear campo: `SCHEMA_VERSION` passa a `1.2.0` (spec 001, R5). +Esta spec muda valores sem remover nem renomear campo, por isso a versão sobe na menor, e não na maior (spec 001, R5): `group_id` passa a ser preenchido; `ascii` ganha valor em cirílico e pode ficar vazio em escrita latina (R2.4), o que a 1.1.0 não permitia; `script` ganha `Jpan`, `Hrkt`, `Kore` e as escritas de R1.4; a capitalização (R3) e a forma de exibição (R4) mudam o texto de `name`. `SCHEMA_VERSION` passa a `1.2.0`. Como `ascii`, `script` e `name` são gravados em `observations.parquet` pelo `ingest` e conferidos pelo `build` (`validate`), `ingest` e `build` de uma release usam a mesma versão do pacote: um `observations.parquet` da 1.1.0 não passa no `build` da 1.2.0 quando tem nome japonês com kana. ### R9. Desempenho e memória @@ -103,11 +103,11 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ## Critérios de aceite -1. `detect_script` devolve `Hani` para `佐々木`, `Kana` para `ユーリ`, `Latn` para `Anna`, `Jpan` para `さくら子`, `Kore` para `김金` e `Orya` para `ଦାସ`. +1. `detect_script` devolve `Hani` para `佐々木`, `Kana` para `ユーリ`, `Latn` para `Anna`, `Jpan` para `さくら子`, `Hrkt` para `ゆかりユカリ`, `Kore` para `김金` e `Orya` para `ଦାସ`. 2. `ascii_form` devolve `dzenana` para `DŽenana`, `o'brien` para `O’Brien`, `eliyev` para `Əliyev`, `ngozi` para `Ŋgozi`, `ivanov` para `Иванов`, `mariia` para `Мария`, `dokovic` para `Ђоковић`, `pelleja` para `Pel·leja` e vazio para `Μαρία` e para um nome latino com letra fora da tabela. 3. Com `uniform_case: true`, `JOANA D'ARC` vira `Joana d'Arc`, `MARIA DEL CARMEN` vira `Maria del Carmen`, `VAN DER BERG` vira `Van der Berg`, `DE LA DE LAS HERAS` vira `De la de las Heras`, `JIA LE` vira `Jia Le` e `D'ARCY` vira `D'Arcy`. 4. Uma tabela de teste em que a forma `A` tem mais nascimentos somados de vários anos num país e a forma `a'` tem mais pessoas vivas em outro país mostra que a forma de exibição não soma `births` com `population`, e que a forma publicada vence a capitalização gerada. -5. Uma tabela de teste com ligações escritas à mão produz os grupos esperados: `Marie-Eve` (CA) com `Marie-Ève`; `Иванов`, `Иванова` e `Ivanov` (transliteração inequívoca) num grupo; `Paulo` e `Paula` separados; `Chen` sem grupo com `陈` e `沈`; `Ines` sem grupo; um par `different_from` separado mesmo ligado por `same_item` a um terceiro nome; nada junta prenome com sobrenome; P460 não junta. +5. Uma tabela de teste com ligações escritas à mão produz os grupos esperados: `Marie-Eve` (CA) com `Marie-Ève`; `Иванов`, `Иванова` e `Ivanov` (transliteração inequívoca) num grupo; `Paulo` e `Paula` separados; `장` sem ponte entre `张` e `蒋`; `見` fora do grupo de `玄` e `현`; `Herman`, `Hermanová` e `Герман` juntos; `Chen` sem grupo com `陈` e `沈`; `Ines` sem grupo; um par `different_from` separado mesmo ligado por `same_item` a um terceiro nome; nada junta prenome com sobrenome; P460 não junta. 6. `run --offline` sobre as amostras grava `given_names.csv` e `family_names.csv` com `group_id` em todas as linhas, `manifest.json` com `schema_version` `1.2.0` e a lista de fontes com as das ligações. 7. `strips_accents` é aceito em `sources.yaml` só como booleano, e as três fontes de R6 o têm `true`. 8. A medição de R9 é feita com os arquivos reais e vai para a descrição do pull request. diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index e5d27d5..cc19eb1 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -78,6 +78,14 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra - [x] `write_observations` com `pq.ParquetWriter` e `LOTE_DE_ESCRITA = 250_000`; `--wikidata` no bench. - [x] `uv run pytest -q` e commit. +### Task 10: correções da revisão do PR #38 + +**Arquivos:** `src/namescommons/variants.py`, `src/namescommons/aggregate.py`, `src/namescommons/normalize.py`, `src/namescommons/schema.py`, `tests/test_variants.py`, `tests/test_normalize.py`, spec, ADR 0010, spec 001 e roadmap. + +- [x] Testes que falham: `장` de `张` e `蒋` não faz ponte; rótulo de dois itens do mesmo grupo junta; `見` -> `현` não entra no grupo de `玄`, nem quando `현` fica sozinho; `Герман` -> `Herman` junta com `Hermanová`; nome sem acento já em outro grupo não junta; ordem embaralhada com transliteração e sem acento; `™`, `ℳ`, `℡` e `𝓜` sem maiúscula em `ascii`; `ー`, `〆` e `Hrkt`; partícula na última palavra. +- [x] Fase 1 por itens (R5.3), concorrência na fase 2 (R5.4), `casefold` depois da NFKD (R2.1), `Hrkt` (R1.3), partícula só com palavra seguinte (R3). +- [x] Medição do agrupamento no lote real (R5.8) e `uv run pytest -q`. + ### Fechamento - [x] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index 6e07948..911b4ac 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -12,8 +12,8 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 3b | [`003b-fontes-planilhas`](003b-fontes-planilhas/spec.md) | 003 | Leitor de XLS e XLSX e institutos que publicam em planilha (INE e outros) | Mesclada | | 3c | `003c-fontes-subnacionais` | 003b | Regra para fontes que cobrem parte de um país e seus adaptadores (ONS, Retraite Québec e outros) | Planejada | | 3d | [`003d-statistics-canada`](003d-statistics-canada/spec.md) | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Mesclada | -| 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Em PR | -| 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Em PR | +| 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Mesclada | +| 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Mesclada | | 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4), PR #38 | Em PR | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | From c417a652b665ae305a54c785eb5c5b391ea8201d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:06:41 -0300 Subject: [PATCH 17/18] fix(variants): block hidden homographs, transliteration fan-out and CJK readings on other items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transliteration source counts as a label of its item, so a source under items of different groups (Герман) is a homograph. A group whose transliterations reach two or more groups of other names joins none of them (Кінаш -> Kinach, Kinasz). Han, kana and hangul romanisations never join a name of another group or item (suk/suková and 术). Cyrillic to Latin joins across items stay (Омельчук -> Omelczuk). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/variants.py | 46 ++++++++++++++++++++++-------- tests/test_variants.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/namescommons/variants.py b/src/namescommons/variants.py index 65ece52..1e9ca2d 100644 --- a/src/namescommons/variants.py +++ b/src/namescommons/variants.py @@ -12,6 +12,9 @@ from namescommons.links import VariantLink +# R5.4: escritas cuja romanização não se anexa a nome de outro item (leituras homófonas). +CJK = frozenset({"Hani", "Hira", "Kana", "Hrkt", "Hang", "Jpan", "Kore"}) + # R5.3: relação entre itens que os junta na fase 1, por kind. ITENS_JUNTADOS_POR = {"given": frozenset(), "family": frozenset({"other_gender"})} @@ -99,6 +102,10 @@ def item(qid: str) -> int: itens_do_no[a].add(item(v.qid)) if b is not None: itens_do_no[b].add(item(v.target_qid)) + elif v.relation == "transliteration" and a is not None: + # A origem de uma transliteração é rótulo do item: um rótulo de itens diferentes + # (Герман de dois itens) é homógrafo, como em same_item. + itens_do_no[a].add(item(v.qid)) if v.relation in ITENS_JUNTADOS_POR[kind]: juncoes_itens.add(_par(item(v.qid), item(v.target_qid))) if a is None or b is None or a == b: @@ -145,26 +152,43 @@ def escrita(i: int) -> str: # R5.4: anexações calculadas sobre os grupos da fase 1, aplicadas depois em ordem fixa. juncoes_2: set[tuple[int, int]] = set() - for destino, fontes in origens.items(): + candidatas: list[tuple[int, int, int, int | None]] = [] # (destino, origem, grupo da origem, grupo rival) + for destino, fontes in sorted(origens.items()): raizes = {grupos.raiz(x) for x in fontes} - if len(raizes) != 1 or destino in ambiguos: + if len(raizes) != 1 or destino in ambiguos or fontes & ambiguos: contar("transliteracao_ambigua") continue (da_origem,) = raizes + do_destino = grupos.raiz(destino) + outro_grupo = do_destino != da_origem and tamanho[do_destino] > 1 + outros_itens = [grupos.raiz(x) for x in itens_do_no.get(destino, ()) if grupos.raiz(x) != da_origem] + escritas_origem = {escrita(x) for x in fontes} # O destino concorre se já é nome de outro grupo ou rótulo de outro item que tem um nome # na escrita da origem (현, rótulo de 玄, não é romanização de 見; Herman, só latino, é de Герман). - rivais: set[str] = set() - do_destino = grupos.raiz(destino) - if do_destino != da_origem and tamanho[do_destino] > 1: - rivais |= escritas_do_grupo[do_destino] - for x in itens_do_no.get(destino, ()): - if grupos.raiz(x) != da_origem: - rivais |= escritas_do_item[grupos.raiz(x)] - if rivais & {escrita(x) for x in fontes}: + rivais: set[str] = set(escritas_do_grupo[do_destino]) if outro_grupo else set() + for r in outros_itens: + rivais |= escritas_do_item[r] + if rivais & escritas_origem: contar("transliteracao_de_outro_nome") continue + # Romanização de han, kana ou hangul é leitura homófona: não se anexa a nome de outro item + # (suk/suková não são romanização de 术). + if escritas_origem & CJK and (outro_grupo or outros_itens): + contar("transliteracao_cjk_de_outro_item") + continue + candidatas.append((destino, min(fontes), da_origem, do_destino if outro_grupo else None)) + # Leque: as transliterações de um grupo que chegam a dois ou mais grupos de outros nomes + # (кінаш -> kinach e kinasz) não se anexam a nenhum deles. + rivais_por_origem: dict[int, set[int]] = defaultdict(set) + for _, _, da_origem, rival in candidatas: + if rival is not None: + rivais_por_origem[da_origem].add(rival) + for destino, origem, da_origem, rival in candidatas: + if rival is not None and len(rivais_por_origem[da_origem]) > 1: + contar("transliteracao_em_leque") + continue contar("transliteracao") - juncoes_2.add(_par(destino, min(fontes))) + juncoes_2.add(_par(destino, origem)) sem_acento = {ascii[i] for i, marca in enumerate(unaccented) if marca and ascii[i]} if sem_acento: por_ascii: dict[str, list[int]] = defaultdict(list) diff --git a/tests/test_variants.py b/tests/test_variants.py index f83c1ee..bb607a8 100644 --- a/tests/test_variants.py +++ b/tests/test_variants.py @@ -2,6 +2,8 @@ import random +import pytest + from namescommons.links import VariantLink from namescommons.normalize import ascii_form, detect_script, name_key from namescommons.variants import group_ids @@ -237,3 +239,55 @@ def test_transliteracao_para_rotulo_de_outro_item_com_rotulo_ambiguo_nao_junta() _l("transliteration", "見", "현", kind="family", qid="Q2")] g = _grupos(["玄", "見", "현", "Hyeon"], ligacoes, kind="family") assert g["見"] != g["현"] + + +# Segunda revisão do PR #38: homógrafos escondidos, leque e romanização de CJK. +def test_origem_de_transliteracao_em_dois_itens_e_homografa(): + """Герман é rótulo de Q1 (-> German) e de Q2 (-> Herman): German, Germán e Herman não se juntam.""" + ligacoes = [_l("transliteration", "Герман", "German", qid="Q1"), + _l("transliteration", "Герман", "Herman", qid="Q2"), + _l("same_item", "German", "Germán", qid="Q3")] + g = _grupos(["Герман", "German", "Germán", "Herman"], ligacoes) + assert g["German"] == g["Germán"] + assert len({g["Герман"], g["German"], g["Herman"]}) == 3 + + +def test_transliteracao_em_leque_nao_junta(): + """кінаш -> kinach e kinasz, sobrenomes poloneses de itens diferentes: nenhum se junta.""" + ligacoes = [_l("transliteration", "Кінаш", "Kinach", kind="family", qid="Q1"), + _l("transliteration", "Кінаш", "Kinasz", kind="family", qid="Q1"), + _l("same_item", "Kinach", "Kinachowa", kind="family", qid="Q2"), + _l("same_item", "Kinasz", "Kinaszowa", kind="family", qid="Q3")] + g = _grupos(["Кінаш", "Kinach", "Kinachowa", "Kinasz", "Kinaszowa"], ligacoes, kind="family") + assert len({g["Кінаш"], g["Kinach"], g["Kinasz"]}) == 3 + + +def test_cirilico_para_grupo_latino_de_outro_item_junta(): + """Омельчук -> Omelczuk, que já está com Omelczukowa: cirílico para latino continua juntando.""" + ligacoes = [_l("transliteration", "Омельчук", "Omelczuk", kind="family", qid="Q1"), + _l("same_item", "Omelczuk", "Omelczukowa", kind="family", qid="Q2")] + g = _grupos(["Омельчук", "Omelczuk", "Omelczukowa"], ligacoes, kind="family") + assert len(set(g.values())) == 1 + + +@pytest.mark.parametrize("origem, latinos", [ + ("术", ("Suk", "Suková")), + ("黑", ("Hak", "Haková")), + ("炳", ("Bing", "Bingová")), + ("辉", ("Fai", "Faiová")), + ("福", ("Fuk", "Fukowa")), + ("郦", ("Lik", "Likowa")), +]) +def test_romanizacao_de_han_nao_entra_em_sobrenome_latino_de_outro_item(origem, latinos): + a, b = latinos + ligacoes = [_l("transliteration", origem, a, kind="family", qid="Q1"), + _l("other_gender", a, b, kind="family", qid="Q2", target_qid="Q3")] + g = _grupos([origem, a, b], ligacoes, kind="family") + assert g[a] == g[b] != g[origem] + + +def test_romanizacao_de_han_sem_outro_item_junta(): + ligacoes = [_l("same_item", "陳", "陈", kind="family", qid="Q1"), + _l("transliteration", "陈", "Chen", kind="family", qid="Q1")] + g = _grupos(["陳", "陈", "Chen"], ligacoes, kind="family") + assert len(set(g.values())) == 1 From 6d5af764201a4142e5f79b88fec8aeb8229d3aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:06:41 -0300 Subject: [PATCH 18/18] docs(variants): record second review numbers and clarify validate in the 1.2.0 note Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0010-regras-de-agrupamento.md | 6 +++--- docs/specs/005-variantes/spec.md | 8 ++++---- docs/specs/005-variantes/tasks.md | 7 +++++++ src/namescommons/schema.py | 3 ++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/adr/0010-regras-de-agrupamento.md b/docs/adr/0010-regras-de-agrupamento.md index 0b78edd..272fac7 100644 --- a/docs/adr/0010-regras-de-agrupamento.md +++ b/docs/adr/0010-regras-de-agrupamento.md @@ -18,8 +18,8 @@ O fecho transitivo das ligações publicadas pelo Wikidata não serve como grupo - Os nós são os pares (`kind`, `name_key`) com observação. As ligações se juntam aos nós pela chave, nunca pelo texto do nome, e são tratadas como não dirigidas. Ligação com ponta sem observação é ignorada. - Prenome e sobrenome nunca se juntam; `group_id` é único dentro de cada `kind`. -- **Fase 1, mesmo item:** os itens se juntam só por `other_gender` em sobrenomes (P5278: `Иванов` e `Иванова` são o mesmo sobrenome flexionado); em prenomes, `other_gender` (P1560: `Paulo` e `Paula`) não junta. Cada nó entra no grupo dos itens de que é rótulo (por `same_item` ou, em sobrenomes, `other_gender`) só se todos esses itens já são um grupo. Um rótulo homógrafo de itens diferentes fica sozinho: na primeira versão desta regra, que juntava por `same_item` direto, `장` ligava `张` e `蒋`, `진` ligava `陈`, `秦` e `真`, e 61 grupos de prenomes e 162 de sobrenomes misturavam itens diferentes (revisão do PR #38). -- **Fase 2, anexação inequívoca,** calculada sobre os grupos da fase 1: a transliteração junta o destino ao grupo de origem só se todas as transliterações que chegam ao destino vêm do mesmo grupo, se o destino não é rótulo homógrafo e se o destino não é já nome de outro grupo ou rótulo de outro item com um nome na escrita da origem (`見` -> `현` não entra no grupo de `玄`, de que `현` é rótulo; `Герман` -> `Herman` entra no grupo latino de `Herman` e `Hermanová`). A regra sem acento (ADR 0011) junta um nome sem acento aos nomes com o mesmo `ascii` só se todos eles estão no mesmo grupo e se o próprio grupo do nome, quando tem outros nomes, é esse. +- **Fase 1, mesmo item:** os itens se juntam só por `other_gender` em sobrenomes (P5278: `Иванов` e `Иванова` são o mesmo sobrenome flexionado); em prenomes, `other_gender` (P1560: `Paulo` e `Paula`) não junta. Cada nó entra no grupo dos itens de que é rótulo (por `same_item`, como origem de transliteração ou, em sobrenomes, `other_gender`) só se todos esses itens já são um grupo. Um rótulo homógrafo de itens diferentes fica sozinho: na primeira versão desta regra, que juntava por `same_item` direto, `장` ligava `张` e `蒋`, `진` ligava `陈`, `秦` e `真`, e 61 grupos de prenomes e 162 de sobrenomes misturavam itens diferentes (revisão do PR #38). +- **Fase 2, anexação inequívoca,** calculada sobre os grupos da fase 1: a transliteração junta o destino ao grupo de origem só se todas as transliterações que chegam ao destino vêm do mesmo grupo, se o destino não é rótulo homógrafo e se o destino não é já nome de outro grupo ou rótulo de outro item com um nome na escrita da origem (`見` -> `현` não entra no grupo de `玄`, de que `현` é rótulo; `Герман` -> `Herman` entra no grupo latino de `Herman` e `Hermanová` quando `Герман` é rótulo de um item só). Romanização de han, kana ou hangul nunca se anexa a nome de outro grupo ou item (leitura homófona: `suk`/`suková` e `术`, na segunda revisão do PR #38), e as transliterações de um grupo que chegam a dois ou mais grupos de outros nomes não se anexam (`Кінаш` -> `Kinach` e `Kinasz`). A regra sem acento (ADR 0011) junta um nome sem acento aos nomes com o mesmo `ascii` só se todos eles estão no mesmo grupo e se o próprio grupo do nome, quando tem outros nomes, é esse. - `said_to_be_the_same` (P460) não junta. Ela continua em `variant_links` para quem quiser cognatos. - `different_from` (P1889) proíbe que dois nós fiquem no mesmo grupo. As junções são aplicadas em ordem fixa (fase, depois os dois `name_key` em ordem de código Unicode); a que violaria a proibição é pulada. - `group_id` é o menor `name_key` do grupo em ordem de código Unicode; nome sem variante é seu próprio grupo. @@ -27,7 +27,7 @@ O fecho transitivo das ligações publicadas pelo Wikidata não serve como grupo ## Consequências -- Os grupos são pequenos (no lote real, o maior tem 21 nomes, todos rótulos do mesmo item) e cada junção tem uma causa nomeável: mesmo item, mesmo sobrenome no outro gênero, transliteração não ambígua ou fonte que remove acentos. Medido: 358 rótulos homógrafos sem ponte, 3.240 transliterações anexadas (898 ambíguas e 25 concorrentes de fora), 14.134 nomes sem acento anexados (3.103 ambíguos de fora) e 248 junções puladas por `different_from`. +- Os grupos são pequenos (no lote real, o maior tem 18 nomes, todos rótulos do mesmo item) e cada junção tem uma causa nomeável: mesmo item, mesmo sobrenome no outro gênero, transliteração não ambígua ou fonte que remove acentos. Medido: 2.310 rótulos homógrafos sem ponte, 2.820 transliterações anexadas (1.305 ambíguas, 27 concorrentes, 9 de CJK para outro item e 2 em leque de fora), 14.136 nomes sem acento anexados (3.099 ambíguos de fora) e 241 junções puladas por `different_from`. Restam 87 grupos de prenomes e 59 de sobrenomes com rótulos de itens diferentes, quase todos o mesmo nome em itens duplicados de línguas diferentes ligados pela transliteração do cirílico (`Алексей` e `Aleksei`, `Омельчук` e `Omelczuk`). - Cognatos (`John`, `Johann`, `Juan`) e grafias alternativas ligadas só pela P460 (`Ivanov` e `Iwanow`) ficam em grupos separados. Quem precisar delas usa `variant_links`. - Romanizações ambíguas, rótulos homógrafos e nomes sem acento ambíguos (`Ines` entre `Inès` e `Inés`) ficam fora de qualquer grupo com variantes, o que perde junções corretas em troca de não criar erradas. - `group_id` muda quando entra no grupo um nome com `name_key` menor; ele identifica o grupo dentro de uma release, não entre releases. diff --git a/docs/specs/005-variantes/spec.md b/docs/specs/005-variantes/spec.md index cb28f87..d0563a4 100644 --- a/docs/specs/005-variantes/spec.md +++ b/docs/specs/005-variantes/spec.md @@ -56,14 +56,14 @@ Cada linha de `given_names` e `family_names` recebe `group_id`. O agrupamento é - **R5.1 Junção pela chave.** Uma ligação de `variant_links` se liga às observações só por (`kind`, `name_key`) e (`target_kind`, `target_name_key`), nunca pelo texto de `name`: a ligação pode trazer uma forma que difere em caixa da forma exibida (`McKenzie` na ligação, `Mckenzie` no índice). Ligação com uma ponta sem observação (por exemplo, transliteração sem fonte que a ateste, 30.649 das 36.909 medidas) é ignorada. Nas relações entre rótulos (`same_item`, `other_gender`, `different_from`) a direção publicada não importa. `transliteration` e a regra sem acento têm direção: o destino da transliteração (um texto) e o nome sem acento são os que se anexam ao grupo do outro lado. - **R5.2 Tipos diferentes nunca se juntam.** Ligação com `kind` diferente de `target_kind` (`family_name_identical`, e as poucas `said_to_be_the_same`, `other_gender` e `different_from` entre prenome e sobrenome) não junta nada. `group_id` é único dentro de cada `kind`. -- **R5.3 Fase 1, mesmo item.** Os itens do Wikidata são juntados entre si só por `other_gender` em sobrenomes (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. Cada nó é rótulo dos itens em que aparece em `same_item` ou, em sobrenomes, em `other_gender`, e entra no grupo desses itens só se todos eles já formam um grupo. Um rótulo homógrafo de itens diferentes (`장`, rótulo coreano de `张` e de `蒋`; `진`, de `陈`, `秦` e `真`) fica sozinho e não faz ponte entre eles. +- **R5.3 Fase 1, mesmo item.** Os itens do Wikidata são juntados entre si só por `other_gender` em sobrenomes (`Иванов` e `Иванова`; P5278). Em prenomes, `other_gender` (P1560, `Paulo` e `Paula`) não junta: são nomes distintos, com `female_share` próprio. Cada nó é rótulo dos itens em que aparece em `same_item`, como origem de `transliteration` ou, em sobrenomes, em `other_gender`, e entra no grupo desses itens só se todos eles já formam um grupo. Um rótulo homógrafo de itens diferentes (`장`, rótulo coreano de `张` e de `蒋`; `진`, de `陈`, `秦` e `真`) fica sozinho e não faz ponte entre eles; o mesmo vale para a origem de transliterações de itens diferentes (`Герман`, de um item com `German` e de outro com `Herman`). - **R5.4 Fase 2, anexação inequívoca.** Calculada sobre os grupos da fase 1: - - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1, se ele não é rótulo homógrafo (R5.3) e se não concorre com outro nome: quando o destino já está num grupo com outros nomes, ou é rótulo de outro item, e esse grupo ou item tem um nome na escrita da origem, o destino pertence àquele nome e a transliteração não junta (`현` é rótulo de `玄`; a transliteração `見` -> `현` não põe `見` no grupo de `玄`). Se o grupo do destino só tem outras escritas, a junção é legítima (`Герман` -> `Herman`, que já está com `Hermanová`). Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. + - `transliteration`: o nó de destino se junta ao grupo de origem só se todas as ligações de transliteração que chegam a ele vêm do mesmo grupo da fase 1, se ele não é rótulo homógrafo (R5.3) e se não concorre com outro nome: quando o destino já está num grupo com outros nomes, ou é rótulo de outro item, e esse grupo ou item tem um nome na escrita da origem, o destino pertence àquele nome e a transliteração não junta (`현` é rótulo de `玄`; a transliteração `見` -> `현` não põe `見` no grupo de `玄`). Se o grupo do destino só tem outras escritas, a junção é legítima (`Герман` -> `Herman`, que já está com `Hermanová`, quando `Герман` é rótulo de um item só), com duas exceções: a romanização de han, kana ou hangul (`Hani`, `Hira`, `Kana`, `Hrkt`, `Hang`, `Jpan`, `Kore`) nunca se anexa a nome de outro grupo ou rótulo de outro item, porque é leitura homófona (`suk` e `suková` não são romanização de `术`); e quando as transliterações de um grupo chegam a dois ou mais grupos de outros nomes (`Кінаш` -> `Kinach` e `Kinasz`), nenhuma dessas se anexa (leque). Uma romanização compartilhada por nomes diferentes (`Chen` de `陈` e de `沈`) não junta nada. A transliteração que o Wikidata publica como qualificador de um rótulo é atribuída pela spec 004 a todos os rótulos do item; isso não muda o resultado, porque os rótulos do mesmo item e `kind` já estão no mesmo grupo pela fase 1, e o adaptador não é alterado. - Sem acento (ADR 0011): um nó latino atestado por fonte com `strips_accents: true` (R6) se junta aos nós latinos do mesmo `kind` com o mesmo `ascii` (R2) só se todos eles estão no mesmo grupo da fase 1, se ele não é rótulo homógrafo e se o grupo dele, quando tem outros nomes, é esse mesmo (um nó cirílico com o mesmo `ascii`, como `Иван` e `Ivan`, não entra: isso é transliteração, não acento). `Marie-Eve` (CA) se junta a `Marie-Ève`; `Ines` (US), com `Inès` e `Inés` em grupos diferentes, não se junta a nenhum. - **R5.5 Não juntam.** `said_to_be_the_same` (P460) não junta: o fecho transitivo dessas ligações forma um componente de 14.039 nomes mesmo com os cortes de R5.6 (medido nos dados reais, ADR 0010). Nomes que só diferem em acento, sem fonte que remove acentos, também não juntam. - **R5.6 `different_from` separa.** Uma ligação `different_from` (P1889) entre dois nós proíbe que eles fiquem no mesmo grupo. As junções são aplicadas em ordem fixa (fase 1: junções entre itens, depois cada nó na ordem do `name_key`; fase 2: pares de `name_key` em ordem de código Unicode), e a junção que poria no mesmo grupo dois nós proibidos é pulada. - **R5.7 Identificador.** `group_id` é o menor `name_key` do grupo em ordem de código Unicode (`marie-eve` para `Marie-Eve` e `Marie-Ève`). Nome sem variante forma um grupo sozinho, e seu `group_id` é o próprio `name_key`. Sem `variant_links` (`build` sem `--links`), só a regra sem acento junta nomes. -- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata; 278.486 prenomes e 729.696 sobrenomes no índice): 358 rótulos homógrafos ficam sem ponte; 3.240 transliterações são anexadas, 898 ficam de fora por ambíguas e 25 por concorrência com outro nome; 14.134 nomes sem acento são anexados e 3.103 ficam de fora por ambíguos; 248 junções são puladas por `different_from`. Resultado: 8.248 grupos com variantes em prenomes (17.712 nomes; o maior com 21, um sobrenome coreano escrito com hanjas diferentes no mesmo item) e 27.387 em sobrenomes (64.679 nomes; o maior com 9). Grupos cujos rótulos de `same_item` vêm de itens diferentes (sem contar os juntados por `other_gender`): de 61 prenomes e 162 sobrenomes, com a primeira versão desta regra, para 1 e 3 (romanizações latinas que também são sobrenome de outro item, como `Lik`). +- **R5.8 Medição.** No lote real completo de 2026-10-12 (fontes oficiais das specs 003, 003b, 003d e 003e mais o Wikidata; 278.486 prenomes e 729.696 sobrenomes no índice): 2.310 rótulos homógrafos ficam sem ponte; 2.820 transliterações são anexadas, 1.305 ficam de fora por ambíguas, 27 por concorrência com nome da mesma escrita, 9 por serem romanização de CJK para nome de outro item e 2 por leque; 14.136 nomes sem acento são anexados e 3.099 ficam de fora por ambíguos; 241 junções são puladas por `different_from`. Resultado: 8.195 grupos com variantes em prenomes (17.443 nomes; o maior com 18, todos rótulos do mesmo item) e 27.224 em sobrenomes (64.112 nomes; o maior com 9). Grupos com rótulos (de `same_item` ou origem de transliteração) de itens diferentes, sem contar os juntados por `other_gender`: 87 em prenomes e 59 em sobrenomes (eram 186 e 273 antes da segunda revisão do PR #38). Quase todos são o mesmo nome em itens duplicados de línguas diferentes, juntados pela transliteração do cirílico para o latim (68 e 51 só com `Cyrl` e `Latn`: `Алексей` e `Aleksei`, `Омельчук` e `Omelczuk`, `Родин`, `Родін` e `Rodin`); dois prenomes têm rótulo CJK no próprio item (`요셉` no item de `Iosif`, `威廉` no de `William`). ### R6. Fontes que removem acentos (`strips_accents`) @@ -86,7 +86,7 @@ As demais fontes publicam acentos (INSEE, CSO, SSB, DIA da Nova Zelândia, Wikid ### R8. Versão -Esta spec muda valores sem remover nem renomear campo, por isso a versão sobe na menor, e não na maior (spec 001, R5): `group_id` passa a ser preenchido; `ascii` ganha valor em cirílico e pode ficar vazio em escrita latina (R2.4), o que a 1.1.0 não permitia; `script` ganha `Jpan`, `Hrkt`, `Kore` e as escritas de R1.4; a capitalização (R3) e a forma de exibição (R4) mudam o texto de `name`. `SCHEMA_VERSION` passa a `1.2.0`. Como `ascii`, `script` e `name` são gravados em `observations.parquet` pelo `ingest` e conferidos pelo `build` (`validate`), `ingest` e `build` de uma release usam a mesma versão do pacote: um `observations.parquet` da 1.1.0 não passa no `build` da 1.2.0 quando tem nome japonês com kana. +Esta spec muda valores sem remover nem renomear campo, por isso a versão sobe na menor, e não na maior (spec 001, R5): `group_id` passa a ser preenchido; `ascii` ganha valor em cirílico e pode ficar vazio em escrita latina (R2.4), o que a 1.1.0 não permitia; `script` ganha `Jpan`, `Hrkt`, `Kore` e as escritas de R1.4; a capitalização (R3) e a forma de exibição (R4) mudam o texto de `name`. `SCHEMA_VERSION` passa a `1.2.0`. `ascii`, `script` e `name` são gravados em `observations.parquet` pelo `ingest`, e o `build` os relê: `validate` confere `script` por igualdade com a escrita detectada em `name` e `ascii` só como ASCII em minúsculas, sem recalcular a transliteração. Por isso `ingest` e `build` de uma release usam a mesma versão do pacote: um `observations.parquet` da 1.1.0 não passa no `build` da 1.2.0 quando tem nome japonês com kana (`script` muda), e passaria com o `ascii` antigo, que ficaria desatualizado. ### R9. Desempenho e memória diff --git a/docs/specs/005-variantes/tasks.md b/docs/specs/005-variantes/tasks.md index cc19eb1..c54067a 100644 --- a/docs/specs/005-variantes/tasks.md +++ b/docs/specs/005-variantes/tasks.md @@ -86,6 +86,13 @@ Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na ra - [x] Fase 1 por itens (R5.3), concorrência na fase 2 (R5.4), `casefold` depois da NFKD (R2.1), `Hrkt` (R1.3), partícula só com palavra seguinte (R3). - [x] Medição do agrupamento no lote real (R5.8) e `uv run pytest -q`. +### Task 11: segunda revisão do PR #38 + +**Arquivos:** `src/namescommons/variants.py`, `tests/test_variants.py`, spec, ADR 0010, `src/namescommons/schema.py`. + +- [x] Testes que falham: `Герман` origem de transliteração em dois itens não junta `German` e `Herman`; leque `Кінаш` -> `Kinach` e `Kinasz`; `Омельчук` -> `Omelczuk` continua juntando; romanização de han (`术`, `黑`, `炳`, `辉`, `福`, `郦`) não entra em sobrenome latino de outro item; `陈` -> `Chen` sem outro item continua juntando. +- [x] Origem de transliteração conta como rótulo do item (R5.3); regras de CJK e de leque (R5.4); métrica com os QIDs das origens de transliteração (R5.8); texto de R8. + ### Fechamento - [x] Medição de R9 com os arquivos reais: `run --offline` com o lote completo e com o sintético mais o Wikidata, na master e nesta branch, nas mesmas condições. diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index 01dcb4b..1e05009 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -10,7 +10,8 @@ # 1.1.0: variant_links.parquet na release (spec 004, R4.7). # 1.2.0 (spec 005, R8): group_id preenchido; ascii em cirílico e possivelmente vazio em latim; # Jpan, Hrkt e Kore; partículas e nova forma de exibição. ingest e build de uma release devem usar -# a mesma versão: validate confere script e ascii gravados pelo ingest. +# a mesma versão: validate confere script por igualdade com detect_script(name) e ascii só como +# ASCII em minúsculas (não recalcula a transliteração). SCHEMA_VERSION = "1.2.0" KINDS = ("given", "family") GENDERS = ("F", "M")