From 80740fbb3fb1099f1668da173e490dce2116fa44 Mon Sep 17 00:00:00 2001 From: lvcas-dotcom Date: Tue, 18 Aug 2026 19:50:12 -0300 Subject: [PATCH 1/5] =?UTF-8?q?fix(infer):=20a=20penalidade=20de=20nome=20?= =?UTF-8?q?gen=C3=A9rico=20ordena,=20e=20n=C3=A3o=20corta=20mais=20sozinha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O spec sempre disse que ela existe para "empurrar os achados valiosos para o fim" — ordenação. A implementação comparava MetaScore, já com a penalidade descontada, contra o limiar, e o candidato sumia antes de qualquer validação. Medido contra Postgres real, schema em inglês, perfil en: category_id → categories somava 0,65, levava -0,30, ficava em 0,35 sob limiar de 0,50 e era descartado. A relação que o dado confirmaria com containment de 100% nunca chegou a ser consultada, e o relatório padrão só mostrava a contagem, sem dizer qual sumiu. Passou despercebido porque com casamento exato a conta fecha em exatamente 0,50 e sobrevive por aritmética. Só morde quando a tabela é plural e o casamento é normalizado, a 0,15 — todo schema Rails ou Django. O teste que cobria a penalidade usava alvo no singular e ainda baixava MinScore para 0,01. O limiar passa a ver cutScore, sem o sinal genérico; MetaScore continua descontando, então a ordenação não muda. A isenção é de um sinal só: alvo ambíguo afirma que a hipótese é menos provável, e continua cortando. Candidato que já estava abaixo do limiar sem a penalidade continua descartado. Custo: uma query de validação a mais por chave de domínio real. O corpus público não mede isto — é DDL sem linha, e a penalidade exige estimativa de linha, então nunca é emitida lá. Recall e contagem de candidatos saem idênticos aos publicados, e a proposal registra isso como limite da medição. --- internal/infer/generate.go | 4 +- internal/infer/generate_test.go | 137 +++++++++++++++++- internal/infer/score.go | 40 +++++ .../penalidade-generica-nao-corta/proposal.md | 94 ++++++++++++ .../specs/candidate-scoring/spec.md | 68 +++++++++ .../penalidade-generica-nao-corta/tasks.md | 59 ++++++++ 6 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/penalidade-generica-nao-corta/proposal.md create mode 100644 openspec/changes/penalidade-generica-nao-corta/specs/candidate-scoring/spec.md create mode 100644 openspec/changes/penalidade-generica-nao-corta/tasks.md diff --git a/internal/infer/generate.go b/internal/infer/generate.go index 03a1a3d..4a72946 100644 --- a/internal/infer/generate.go +++ b/internal/infer/generate.go @@ -530,7 +530,9 @@ func finalize(res *Result, minScore float64) { survivors := res.Candidates[:0:0] for _, c := range res.Candidates { - if c.MetaScore >= minScore { + // cutScore, not MetaScore: the generic-name penalty ranks and never + // cuts on its own. See its doc comment for why the two differ. + if cutScore(c) >= minScore { survivors = append(survivors, c) continue } diff --git a/internal/infer/generate_test.go b/internal/infer/generate_test.go index 8ced257..8113f5a 100644 --- a/internal/infer/generate_test.go +++ b/internal/infer/generate_test.go @@ -59,6 +59,27 @@ func find(res *infer.Result, child, parent string) (model.Candidate, bool) { return model.Candidate{}, false } +// survivor finds a candidate among the ones that reached validation only. +// find looks in both lists, which is what most tests want and exactly what a +// test about the threshold must not do. +func survivor(res *infer.Result, child, parent string) (model.Candidate, bool) { + for _, c := range res.Candidates { + if c.Child.String() == child && c.Parent.String() == parent { + return c, true + } + } + return model.Candidate{}, false +} + +func discarded(res *infer.Result, child, parent string) (model.Candidate, bool) { + for _, c := range res.Discarded { + if c.Child.String() == child && c.Parent.String() == parent { + return c, true + } + } + return model.Candidate{}, false +} + func TestGeneratesTheObviousCandidate(t *testing.T) { res := generate(t, schema( tbl("cliente"), @@ -192,9 +213,7 @@ func TestGenericNameWithSmallTargetIsPenalized(t *testing.T) { status := tbl("status") status.Stats.EstimatedRows = 6 - res := generate(t, schema(status, tbl("pedido", col("status_id"))), func(o *infer.Options) { - o.MinScore = 0.01 - }) + res := generate(t, schema(status, tbl("pedido", col("status_id")))) c, ok := find(res, "public.pedido.status_id", "public.status.id") if !ok { @@ -205,6 +224,118 @@ func TestGenericNameWithSmallTargetIsPenalized(t *testing.T) { } } +// TestGenericPenaltyRanksButDoesNotCut pins the boundary the penalty must not +// cross. It ranks a boring relationship last; it does not decide the +// relationship is false. +// +// The case is the ordinary one, not a contrived one: a plural domain table. +// An exact name match absorbs the penalty and survives on arithmetic alone +// (0.30+0.25+0.20+0.05-0.30 = 0.50, exactly the threshold), which is why this +// went unnoticed — the bug only bites when the table is plural and the name +// match is therefore normalized rather than exact, at 0.15. That is every +// Rails and Django schema in existence: categories, statuses, types. +func TestGenericPenaltyRanksButDoesNotCut(t *testing.T) { + tipos := tbl("tipos") + tipos.Stats.EstimatedRows = 6 + + res := generate(t, schema(tipos, tbl("pedido", col("tipo_id")))) + + c, ok := survivor(res, "public.pedido.tipo_id", "public.tipos.id") + if !ok { + t.Fatal("a generic name must still reach validation: the penalty says boring, not false") + } + if !c.HasSignal(model.SigGenericDomain) { + t.Fatal("the candidate must carry the penalty; without it this test proves nothing") + } + // Without this the test would pass vacuously the day the weights move and + // the penalized score stops falling below the threshold at all. + if c.MetaScore >= infer.DefaultMinScore { + t.Errorf("MetaScore = %.2f, which already clears the %.2f threshold: "+ + "this case no longer exercises the exemption", + c.MetaScore, infer.DefaultMinScore) + } +} + +// TestGenericPenaltyStillSortsLast is the other half: exempting the penalty +// from the cut must not exempt it from the ordering, which is the whole reason +// it exists. +func TestGenericPenaltyStillSortsLast(t *testing.T) { + tipos := tbl("tipos") + tipos.Stats.EstimatedRows = 6 + + res := generate(t, schema( + tipos, + tbl("cliente"), + tbl("pedido", col("tipo_id"), col("cliente_id")), + )) + + boring, ok := survivor(res, "public.pedido.tipo_id", "public.tipos.id") + if !ok { + t.Fatal("the domain candidate should have survived the cut") + } + interesting, ok := survivor(res, "public.pedido.cliente_id", "public.cliente.id") + if !ok { + t.Fatal("the ordinary candidate should have survived the cut") + } + if boring.MetaScore >= interesting.MetaScore { + t.Errorf("domain candidate scores %.2f against %.2f: the penalty has to keep ranking", + boring.MetaScore, interesting.MetaScore) + } +} + +// TestGenericPenaltyDoesNotRescueAWeakCandidate keeps the exemption narrow. +// A candidate the threshold would have cut on its other signals is cut, penalty +// or no penalty — otherwise the exemption becomes a way for the weakest +// hypotheses in the schema to reach the user's database. +func TestGenericPenaltyDoesNotRescueAWeakCandidate(t *testing.T) { + tipos := tbl("tipos") + tipos.Stats.EstimatedRows = 6 + + // Nullable and merely compatible in type: 0.15 + 0.10 + 0.20 = 0.45, + // under the threshold before the penalty is even considered. + weak := model.Column{Name: "tipo_id", Type: "integer", BaseType: "int4", Nullable: true} + + res := generate(t, schema(tipos, tbl("pedido", weak))) + + c, ok := discarded(res, "public.pedido.tipo_id", "public.tipos.id") + if !ok { + t.Fatal("a candidate weak on its own signals must still be discarded") + } + if c.Reason == "" { + t.Error("a discard must carry its reason") + } +} + +// TestAmbiguousPenaltyStillCuts keeps the exemption to one signal. The other +// negative signals say the hypothesis is less likely to be true, which is what +// a confidence threshold is for; only the generic-name penalty says the +// relationship is uninteresting. +func TestAmbiguousPenaltyStillCuts(t *testing.T) { + // The same entity name in two schemas: neither target can be the one + // meant, and the tool does not get to pick. + first := model.Schema{Name: "public", Tables: []model.Table{ + tbl("cliente"), + tbl("pedido", model.Column{Name: "cliente_id", Type: "integer", BaseType: "int4", Nullable: true}), + }} + second := model.Schema{Name: "arquivo", Tables: []model.Table{tbl("cliente")}} + for i := range second.Tables { + second.Tables[i].Schema = "arquivo" + } + + res := generate(t, []model.Schema{first, second}) + + c, ok := find(res, "public.pedido.cliente_id", "public.cliente.id") + if !ok { + t.Fatal("the candidate should have been generated") + } + if !c.HasSignal(model.SigAmbiguousTarget) { + t.Fatal("two tables answering to the same name must be marked ambiguous") + } + if _, still := survivor(res, "public.pedido.cliente_id", "public.cliente.id"); still { + t.Error("ambiguity is a statement about truth, not about interest: it has to keep cutting") + } +} + // TestGenericNameWithLargeTargetIsNotPenalized is the other half of the rule: a // generic name pointing at a large table is not a domain table at all. func TestGenericNameWithLargeTargetIsNotPenalized(t *testing.T) { diff --git a/internal/infer/score.go b/internal/infer/score.go index 7a4a2cf..c8c2b9f 100644 --- a/internal/infer/score.go +++ b/internal/infer/score.go @@ -111,7 +111,13 @@ func score(signals []model.Signal) float64 { for _, s := range signals { total += s.Weight } + return saturate(total) +} +// saturate is the single owner of the range rule. score and every derived +// score go through it, so a number computed one way and a number computed +// another are comparable against the same threshold. +func saturate(total float64) float64 { switch { case total < 0: return 0 @@ -122,6 +128,40 @@ func score(signals []model.Signal) float64 { } } +// cutScore is the score the threshold is applied to, which is not always the +// score the candidate reports. +// +// The generic-name penalty is the one signal that says nothing about whether +// the relationship is real. Every other negative signal lowers the probability +// of the hypothesis — an ambiguous target means the tool genuinely does not +// know which table is meant; a merely compatible type means the columns might +// not be the same thing. SigGenericDomain says the opposite: these +// relationships "costumam ser reais e pouco interessantes" +// (openspec/specs/candidate-scoring), and the penalty exists so that dozens of +// them do not push the valuable findings to the bottom of the report. +// +// That is a statement about rank, not about truth. Letting it reach the cut +// made it a filter: status_id → status, type_id → type, category_id → +// categories — the most ordinary lookup keys there are — scored 0.65, took the +// -0.30, landed at 0.35 and were dropped before validation ever ran. The tool +// then never learned they were 100% contained, and the report named a count +// with no relation beside it. A key discarded for being boring is the one +// discard the user cannot argue with, because the argument was never shown. +// +// So the penalty ranks and never cuts alone: the threshold sees the score +// without it, and MetaScore keeps it so the candidate still sorts last. Any +// candidate that would have been cut anyway is cut anyway. +func cutScore(c model.Candidate) float64 { + total := 0.0 + for _, s := range c.Signals { + if s.Kind == model.SigGenericDomain { + continue + } + total += s.Weight + } + return saturate(total) +} + // Rescore recomputes MetaScore from the candidate's signals, saturating exactly // as generation does. A layer that appends signals after generation must // recompose through here: a second implementation of the combination rule would diff --git a/openspec/changes/penalidade-generica-nao-corta/proposal.md b/openspec/changes/penalidade-generica-nao-corta/proposal.md new file mode 100644 index 0000000..318e253 --- /dev/null +++ b/openspec/changes/penalidade-generica-nao-corta/proposal.md @@ -0,0 +1,94 @@ +## Why + +A penalidade de nome genérico (`penaltyGenericDomain`, `internal/infer/score.go`) existe, +pelo que o próprio spec diz, para **ordenar**: + +> O sinal de nome genérico existe porque essas relações costumam ser reais e pouco +> interessantes, e sem penalidade elas dominariam o relatório por volume, **empurrando os +> achados valiosos para o fim**. +> — `openspec/specs/candidate-scoring/spec.md` + +A implementação a fazia **filtrar**. `finalize` compara `MetaScore` — já com a penalidade +descontada — contra `MinScore`, e o candidato some antes de qualquer validação. + +Medido contra um Postgres de verdade, schema em inglês com tabelas no plural, perfil `en`: + +``` +DISCARDED public.products.category_id → public.categories.id + score 0.35 below threshold 0.50 +``` + +O detalhamento dos sinais mostra a aritmética inteira: + +``` +normalized_name=0.15 identical_type=0.25 unique_target=0.20 not_null=0.05 +generic_domain_name=-0.30 → 0.35 +``` + +Sem a penalidade seriam 0,65, bem acima do limiar. Com ela, 0,35 — e a relação, que a +validação teria confirmado com containment de 100%, nunca chegou a ser consultada. Vale +para `status`, `type`, `kind`, `state`, `level`, `category`, `origin` e `classification`: +a lista `generic_entities` inteira, em toda tabela de domínio pequena. + +Por que passou despercebido: com casamento **exato** a conta fecha em +`0.30+0.25+0.20+0.05-0.30 = 0.50`, exatamente o limiar, e o candidato sobrevive por +aritmética. O corte só morde quando o nome da tabela é plural e o casamento é portanto +normalizado, a 0,15 — que é toda tabela de domínio de schema Rails, Django ou qualquer um +que pluralize. O teste que cobria a penalidade +(`TestGenericNameWithSmallTargetIsPenalized`) usava alvo no singular **e** baixava +`MinScore` para 0,01, então nunca exercitou o limiar de verdade. + +Isso quebra duas regras invioláveis de `openspec/project.md` ao mesmo tempo. A regra 4, +porque o descarte aparece no relatório padrão como contagem sem nome — só +`--include-rejected` diz qual relação sumiu, e o README promete o contrário. E a regra 3 na +direção oposta à usual: a ferramenta afirmou "abaixo do limiar" sobre uma relação cuja +evidência ela se recusou a coletar. + +O que não é motivo para mudar: "recall baixo". Recuperar mais nunca justifica sozinho +mexer no corte — a regra 5 diz que na dúvida entre recuperar mais e nunca errar, nunca +errar vence. O motivo é que este sinal específico não fala sobre verdade. + +## What Changes + +- `internal/infer/score.go`: `score` passa a delegar a saturação a `saturate`, dono único + da regra de faixa. Novo `cutScore`, que soma os sinais do candidato **exceto** + `SigGenericDomain` e satura pelo mesmo `saturate`. +- `internal/infer/generate.go`: `finalize` compara `cutScore(c)` com o limiar, e não + `MetaScore`. `MetaScore` continua com a penalidade descontada, então a ordenação — a + razão de a penalidade existir — não muda em nada. +- A isenção é de um sinal só. `penaltyAmbiguousTarget` continua cortando: alvo ambíguo é + afirmação sobre probabilidade da hipótese — a ferramenta genuinamente não sabe qual + tabela é —, e tipo apenas compatível também. Nome genérico é a única penalidade que diz + "pouco interessante" em vez de "provavelmente falso". +- A isenção não resgata candidato fraco: quem já estava abaixo do limiar sem a penalidade + continua descartado, com motivo registrado. + +### Custo + +Uma query de validação a mais por chave de domínio real, contra o banco do usuário. No +schema de medição: 4 candidatos validados antes, 5 depois. É o custo de validar uma +relação em vez de descartá-la sem olhar. + +**O corpus público não consegue medir isto, e o relatório precisa dizer isso em vez de +publicar "custo zero".** `make benchmark` rodado nesta branch devolve recall e contagem de +candidatos **idênticos** aos publicados — GitLab 1066/1069/1754/1757, Discourse +403/396/408/400. O motivo não é que a mudança não faz nada: é que a penalidade exige +`rows < smallTableRows` **e** estimativa conhecida (`internal/infer/generate.go`), e um +`structure.sql` não tem linha nenhuma. No corpus a penalidade nunca é emitida, então não +há o que isentar. A medição desta change é contra banco com dado — fixtures de integração +e o schema do `docs/DEMO.md`, cuja saída publicada não muda. + +## Alternatives rejected + +**Tirar `category`, `status` e companhia de `generic_entities`.** Resolve o sintoma e +apaga o sinal: a ordenação que a penalidade entrega é boa e é o motivo de ela existir. Além +disso teria de ser repetido em `en`, `pt-br` e `es`, e no perfil que o próximo contribuidor +escrever. + +**Baixar a penalidade de -0,30 para algo que não cruze o limiar.** Número escolhido para +caber no limiar padrão, que é configurável — `--min-score 0.7` traz o bug de volta. Troca +uma regra por uma coincidência aritmética. + +**Deixar como está e mostrar os descartados por padrão.** Não resolve: a relação continua +sem ser validada, e o usuário lê "descartado por score" sobre uma chave que o dado +confirmaria. Mostrar mais sobre uma resposta errada não a torna certa. diff --git a/openspec/changes/penalidade-generica-nao-corta/specs/candidate-scoring/spec.md b/openspec/changes/penalidade-generica-nao-corta/specs/candidate-scoring/spec.md new file mode 100644 index 0000000..c29c500 --- /dev/null +++ b/openspec/changes/penalidade-generica-nao-corta/specs/candidate-scoring/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Sinais negativos e o que eles protegem + +O sistema SHALL emitir sinais negativos para: nome de entidade genérico apontando para tabela pequena, tipicamente tabela de domínio como `status` ou `tipo`; múltiplas tabelas candidatas para o mesmo nome; e tipo compatível mas não idêntico. + +O sinal de nome genérico existe porque essas relações costumam ser reais e pouco interessantes, e sem penalidade elas dominariam o relatório por volume, empurrando os achados valiosos para o fim. + +Por isso ele SHALL afetar a ordenação e MUST NOT ser, sozinho, o que descarta um candidato. O limiar SHALL ser aplicado ao score do candidato calculado **sem** o sinal de nome genérico; o score reportado SHALL continuar contando a penalidade, para que a ordenação permaneça inalterada. + +Os demais sinais negativos SHALL continuar entrando no corte. A distinção é o que cada um afirma: alvo ambíguo e tipo apenas compatível reduzem a probabilidade de a hipótese ser verdadeira, e um limiar de confiança é exatamente onde isso deve pesar. Nome genérico afirma que a relação é pouco interessante, não que seja provavelmente falsa — deixá-lo cortar transforma "chato" em "inexistente", e a relação descartada é justamente aquela cuja validação teria sido conclusiva. + +Um candidato que já ficaria abaixo do limiar sem a penalidade SHALL continuar descartado, com o motivo registrado. + +#### Scenario: Nome genérico com alvo pequeno é penalizado + +- **WHEN** a coluna `status_id` casa com uma tabela `status` de poucas linhas +- **THEN** o candidato carrega o sinal de nome genérico e tem score reduzido + +#### Scenario: Nome genérico não é motivo de descarte + +- **WHEN** um candidato só fica abaixo do limiar por causa do sinal de nome genérico +- **THEN** ele segue para validação, e seu score reportado continua descontando a penalidade + +#### Scenario: A penalidade continua ordenando + +- **WHEN** um candidato penalizado por nome genérico e outro sem penalidade são reportados +- **THEN** o penalizado aparece depois, com score estritamente menor + +#### Scenario: A isenção não resgata candidato fraco + +- **WHEN** um candidato ficaria abaixo do limiar mesmo sem o sinal de nome genérico +- **THEN** ele é descartado antes da validação, com o motivo registrado + +#### Scenario: Ambiguidade é penalizada + +- **WHEN** um nome de entidade casa com mais de uma tabela +- **THEN** todos os candidatos resultantes carregam o sinal de alvo ambíguo e têm score reduzido + +#### Scenario: Ambiguidade continua cortando + +- **WHEN** um candidato fica abaixo do limiar por causa do sinal de alvo ambíguo +- **THEN** ele é descartado antes da validação, com o motivo registrado + +### Requirement: Corte por limiar configurável com motivo registrado + +Candidatos com score abaixo de um limiar configurável SHALL ser descartados antes de qualquer validação, e cada descarte SHALL registrar o motivo. + +Este corte é o único mecanismo que impede a fase de validação de disparar milhares de anti-joins contra um banco de produção, e por isso o limiar precisa ser ajustável sem recompilar. + +O padrão SHALL ser conservador o bastante para que uma execução em schema de centenas de tabelas produza um conjunto de candidatos que caiba numa revisão humana. + +O score comparado ao limiar SHALL ser o score de corte definido no requisito de sinais negativos — o do candidato sem o sinal de nome genérico —, e não necessariamente o score reportado. Os dois SHALL usar a mesma regra de saturação, sob um único dono, para que continuem comparáveis contra o mesmo limiar. + +#### Scenario: Abaixo do limiar é descartado + +- **WHEN** um candidato pontua abaixo do limiar configurado +- **THEN** ele não segue para validação, e o motivo do descarte fica registrado + +#### Scenario: Descartes são reportáveis + +- **WHEN** o usuário pede para ver os descartados +- **THEN** eles aparecem com score e motivo, para que ninguém se pergunte por que uma coluna óbvia foi ignorada + +#### Scenario: Limiar é ajustável + +- **WHEN** o usuário fornece um limiar diferente do padrão +- **THEN** o conjunto de candidatos sobreviventes muda de acordo diff --git a/openspec/changes/penalidade-generica-nao-corta/tasks.md b/openspec/changes/penalidade-generica-nao-corta/tasks.md new file mode 100644 index 0000000..0df1c0d --- /dev/null +++ b/openspec/changes/penalidade-generica-nao-corta/tasks.md @@ -0,0 +1,59 @@ +## 1. Score + +- [x] 1.1 `internal/infer/score.go`: extrair `saturate` de `score`, dono único da + regra de faixa, para que score reportado e score de corte continuem + comparáveis contra o mesmo limiar +- [x] 1.2 `internal/infer/score.go`: `cutScore` soma os sinais do candidato + exceto `SigGenericDomain` e satura por `saturate`, com o comentário + explicando por que este sinal e nenhum outro + +## 2. Corte + +- [x] 2.1 `internal/infer/generate.go`: `finalize` compara `cutScore(c)` com o + limiar em vez de `MetaScore`; `MetaScore` fica intacto, então a ordenação + não muda + +## 3. Teste + +- [x] 3.1 `TestGenericPenaltyRanksButDoesNotCut`: alvo no **plural**, para bater + no caminho de casamento normalizado que é onde o bug morde; verifica + também que o `MetaScore` reportado ainda está abaixo do limiar, senão o + teste passaria vazio no dia em que os pesos mudarem +- [x] 3.2 `TestGenericPenaltyStillSortsLast`: a isenção do corte não isenta da + ordenação +- [x] 3.3 `TestGenericPenaltyDoesNotRescueAWeakCandidate`: quem já estava abaixo + do limiar sem a penalidade continua descartado, com motivo +- [x] 3.4 `TestAmbiguousPenaltyStillCuts`: a isenção é de um sinal só; alvo + ambíguo continua cortando +- [x] 3.5 Não há teste para "o score de corte satura pela mesma regra": a + propriedade não é observável pela superfície pública — remover uma + penalidade só pode aumentar a soma, e o teto de 1 leva à mesma decisão de + corte com ou sem saturação. Toda a suíte do pacote é black-box + (`package infer_test`), e abrir um arquivo white-box só para isso seria + teste de fachada. O cenário saiu do spec delta em vez de ganhar um teste + que não prova nada; o dono único da regra continua garantido por + `saturate`, e pelo requisito de recomposição que já existia +- [x] 3.6 Helpers `survivor` e `discarded` em `generate_test.go`: `find` procura + nas duas listas, que é o que a maioria dos testes quer e exatamente o que + um teste sobre o limiar não pode fazer +- [x] 3.7 Remover o `MinScore = 0.01` de `TestGenericNameWithSmallTargetIsPenalized`, + que era contorno do bug e escondia que o caminho exato sobrevive por + aritmética + +## 4. Validação + +- [x] 4.1 `go build ./...`, `go build -tags=integration ./...`, + `go build -tags=benchmark ./...` — limpos +- [x] 4.2 `go test ./...` — verde +- [x] 4.3 `go test -tags integration ./...` — verde +- [x] 4.4 `golangci-lint run` sob as três tags — sem achado +- [x] 4.5 Os testes novos falham sem o fix (`finalize` revertido para + `MetaScore`): `TestGenericPenaltyRanksButDoesNotCut` e + `TestGenericPenaltyStillSortsLast` ficam vermelhos +- [x] 4.6 Medido contra Postgres real: schema em inglês com tabelas no plural, + perfil `en`, sai de 4 para 5 confirmados, e os dois genéricos aparecem no + fim da lista +- [x] 4.7 `docs/DEMO.md` reproduzido: a saída publicada no README não muda +- [x] 4.8 `make benchmark` rodado: recall e contagem de candidatos idênticos aos + publicados, porque o corpus é DDL sem linha e a penalidade nunca é emitida + lá — registrado na proposal como limite da medição, não como custo zero From 8c066b215aa81f4b710359765ca57d801f78522f Mon Sep 17 00:00:00 2001 From: lvcas-dotcom Date: Tue, 18 Aug 2026 19:50:26 -0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(bench):=20o=20relat=C3=B3rio=20publicad?= =?UTF-8?q?o=20diz=20quais=20schemas=20n=C3=A3o=20foram=20medidos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bench/corpus.toml promete que o dump local ausente é declarado em vez de omitido, e o comentário do harness repete a promessa. A declaração ia para t.Logf — o log do teste, que docs/benchmark/recall.md não carrega. Quem rodasse `make benchmark` sem o dump opcional republicava o arquivo com a seção do municipal-ptbr apagada, e o resultado lê como corpus completo em vez de corpus parcialmente medido. A remoção é invisível na revisão a menos que alguém compare com uma cópia que por acaso lembrasse. WriteReports passa a receber os pulados e a nomeá-los no documento. --- internal/bench/corpus_test.go | 12 +++++++++--- internal/bench/report.go | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/bench/corpus_test.go b/internal/bench/corpus_test.go index f49f9bf..2dbad97 100644 --- a/internal/bench/corpus_test.go +++ b/internal/bench/corpus_test.go @@ -60,13 +60,19 @@ func TestCorpus(t *testing.T) { started := time.Now() var results []bench.EntryResult + var skipped []bench.Skipped for _, e := range manifest.Schemas { if os.Getenv("PGFATHOM_BENCH_DSN_"+strings.ToUpper(strings.ReplaceAll(e.Name, "-", "_"))) == "" && !bench.Ready(e) { if e.Kind == bench.FromLocal { // Optional by design: a real dump cannot live in a public - // manifest. Its absence is stated rather than omitted. - t.Logf("SKIP %s: optional local schema absent from this machine", e.Name) + // manifest. Its absence is stated rather than omitted — in the + // published report, not only here, or regenerating the file on + // a machine without the dump would delete the row and read as + // if it had never been measured at all. + const reason = "optional local dump, absent from the machine that ran this" + t.Logf("SKIP %s: %s", e.Name, reason) + skipped = append(skipped, bench.Skipped{Name: e.Name, Reason: reason}) continue } t.Fatalf("%s is not in the cache; run: make corpus", e.Name) @@ -79,7 +85,7 @@ func TestCorpus(t *testing.T) { if len(results) == 0 { t.Fatal("no schema was measured") } - if err := bench.WriteReports(results, time.Since(started)); err != nil { + if err := bench.WriteReports(results, skipped, time.Since(started)); err != nil { t.Fatalf("writing reports: %v", err) } } diff --git a/internal/bench/report.go b/internal/bench/report.go index f2d00fd..4d44ccd 100644 --- a/internal/bench/report.go +++ b/internal/bench/report.go @@ -26,15 +26,33 @@ const ( FileCost = "cost.md" ) +// Skipped is a manifest entry this run could not measure, and why. +// +// It exists because the alternative is worse than it looks. A schema the +// harness cannot reach simply produces no section, and the regenerated file +// reads as complete — the reader has no way to tell a corpus of two from a +// corpus of three measured on a machine that held two. Whoever runs +// `make benchmark` without the optional local dump would republish the file +// with a row silently deleted, and the deletion is invisible in review unless +// somebody diffs against a copy they happened to remember. +// +// The manifest already promised this: "presente na máquina, é medido; ausente, +// o relatório diz que não foi" (bench/corpus.toml). It was said to the test +// log, which the published document does not carry. +type Skipped struct { + Name string + Reason string +} + // WriteReports renders both files under docs/benchmark. -func WriteReports(results []EntryResult, elapsed time.Duration) error { +func WriteReports(results []EntryResult, skipped []Skipped, elapsed time.Duration) error { dir := filepath.Join(repoRoot(), "docs", "benchmark") if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("creating report directory: %w", err) } for name, content := range map[string]string{ - FileRecall: renderRecall(results), + FileRecall: renderRecall(results, skipped), FileCost: renderCost(results, elapsed), } { path := filepath.Join(dir, name) @@ -45,7 +63,7 @@ func WriteReports(results []EntryResult, elapsed time.Duration) error { return nil } -func renderRecall(results []EntryResult) string { +func renderRecall(results []EntryResult, skipped []Skipped) string { var b strings.Builder b.WriteString("# Recovery rate on the corpus\n\n") @@ -77,6 +95,17 @@ func renderRecall(results []EntryResult) string { fmt.Fprintf(&b, "Tool `%s`.\n\n", version()) + if len(skipped) > 0 { + b.WriteString("**Not measured on this run.** The manifest names schemas this machine\n") + b.WriteString("could not reach, so the sections below are the corpus minus these. A file\n") + b.WriteString("regenerated without them is not a smaller corpus; it is the same corpus,\n") + b.WriteString("partly measured.\n\n") + for _, s := range skipped { + fmt.Fprintf(&b, "- `%s` — %s\n", s.Name, s.Reason) + } + b.WriteString("\n") + } + for _, r := range results { fmt.Fprintf(&b, "## %s\n\n", r.Entry.Name) From 57261d100135a51fc0c7976c8c2a6bc9fafc95f7 Mon Sep 17 00:00:00 2001 From: lvcas-dotcom Date: Tue, 18 Aug 2026 19:50:26 -0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(test):=20a=20su=C3=ADte=20de=20integra?= =?UTF-8?q?=C3=A7=C3=A3o=20para=20de=20falhar=20no=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestStagesAccountForTheRun exigia duração estritamente positiva por estágio. A detecção de nomenclatura é trabalho em memória sobre catálogo já lido — a tabela de custo publicada a imprime como `0s` mesmo no Linux — e o relógio monotônico do Go no Windows é mais grosso que isso: medi 200 de 200 amostras sub-milissegundo dando exatamente zero. A suíte ficava vermelha num clone limpo para quem contribui daquela plataforma, e o CI nunca pegava porque só roda integração no ubuntu. Zero é medição verdadeira de trabalho mais rápido que o relógio. O que a asserção queria pegar — estágio que sumiu — é pego pela lista exaustiva de estágios no fim do teste, porque um estágio pulado não gera entrada nenhuma (ver timeline.mark). Passa a rejeitar só duração negativa. --- .../discovery/discovery_integration_test.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/discovery/discovery_integration_test.go b/internal/discovery/discovery_integration_test.go index 7d806bf..b980210 100644 --- a/internal/discovery/discovery_integration_test.go +++ b/internal/discovery/discovery_integration_test.go @@ -232,10 +232,23 @@ func TestStagesAccountForTheRun(t *testing.T) { t.Fatal("a finished run must report where its time went") } + // Negative, not zero. A stage that quietly dropped out contributes no + // entry at all (see timeline.mark), so what catches it is the exhaustive + // stage list at the end of this test, not a duration. + // + // Requiring every stage to be strictly positive tested the clock instead + // of the code, and failed on Windows for a real reason: Go's monotonic + // clock there is coarser than the fastest stage. Naming detection is pure + // in-memory work over the catalog already read — it measures under a + // millisecond even on Linux, where the published cost table prints it as + // `0s` — so on Windows it measures exactly zero, and the suite went red on + // a clean clone for whoever contributes from that platform. Zero is a + // truthful measurement of work faster than the clock can see. Negative + // would mean the timer itself went backwards. var sum time.Duration for _, s := range res.Stages { - if s.Duration <= 0 { - t.Errorf("stage %q reports %s", s.Stage, s.Duration) + if s.Duration < 0 { + t.Errorf("stage %q reports %s: durations cannot run backwards", s.Stage, s.Duration) } sum += s.Duration } From badba7395749841956343cff0314cf8810736b7a Mon Sep 17 00:00:00 2001 From: lvcas-dotcom Date: Tue, 18 Aug 2026 19:50:39 -0300 Subject: [PATCH 4/5] docs: corrige o que o README afirmava sobre si mesmo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Four dependencies in the binary" era verdade em 2026-08-06 e envelheceu quando o guia interativo entrou. São seis diretas e vinte e cinco módulos linkados, 11 MB — medido no artefato de release, linux/amd64, como openspec/project.md exige. A frase está no parágrafo que diz que quem aprova rodar isto em produção abre o go.mod primeiro; era a afirmação mais fácil de conferir e a única errada. O default de perfil é pt-br e o README não dizia isso em lugar nenhum. Quem roda contra schema em inglês lê `profile pt-br` na primeira linha da saída sem nunca ter escolhido. Agora diz, e diz o que --profile en compra: as regras de plural. Sem elas o casamento cai no fallback lexical, com metade do peso de nome (0,08 contra 0,15 para category_id, medido), não some — a primeira versão desta frase dizia "não gera candidato", que é falso. "Weak and rejected are reported too" era verdade sobre weak e meia verdade sobre rejected: o descarte é contado em toda execução e nomeado por --include-rejected. O badge de status era "pre-release" escrito à mão, com três releases publicados. Passa a ler o release mais recente do GitHub, para não envelhecer de novo. --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 31 ++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb305a..d57b637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,12 +37,70 @@ change between minor versions; both are versioned and documented when they do. print it. A schema whose only detected convention was that one got a section announcing conventions and naming none. +### Fixed + +- **The most ordinary foreign keys in an English schema are no longer discarded + unseen.** A domain table — `categories`, `statuses`, `types` — carries a + penalty so that dozens of uninteresting-but-real relationships do not bury the + findings that justify the tool. That penalty was reaching the score threshold, + which is a different thing entirely: `category_id → categories` scored 0.65, + took the -0.30, landed at 0.35 under a threshold of 0.50, and was dropped + before validation ever ran. The relationship the data would have confirmed at + 100% containment was never looked at, and the report named a count with no + relation beside it. + + The penalty now ranks and never cuts on its own: the threshold sees the score + without it, and the reported score still carries it, so the ordering it exists + for is unchanged. Every other negative signal still cuts — an ambiguous target + means the tool does not know which table is meant, which is exactly what a + confidence threshold is for. A candidate already below the threshold on its + other signals is still discarded, with its reason. + + It bit hardest where the table name is plural and the name match is therefore + normalized rather than exact, which is every Rails and Django schema. Measured + against a real server, an English schema goes from 4 confirmed to 5. The cost + is one validation query per real domain key. The public corpus cannot measure + this either way: it is DDL without a single row, so the penalty, which needs a + row estimate, is never emitted there at all. + +- **The benchmark report says which corpus schemas it could not measure.** The + manifest promises that an absent local dump is stated rather than omitted; the + statement went to the test log, which the published document does not carry. + Regenerating `docs/benchmark/recall.md` on a machine without the optional dump + silently deleted that schema's section, and the result read as a complete + corpus rather than a partly measured one. + +- **The integration suite no longer fails on Windows.** One assertion required + every pipeline stage to report a strictly positive duration. Naming detection + is in-memory work over a catalog already read — the published cost table + prints it as `0s` even on Linux — and Go's monotonic clock on Windows is + coarser than that, so it measured exactly zero and the suite went red on a + clean clone. Durations are now allowed to be zero and rejected only if + negative; a stage that drops out is caught by the exhaustive stage list, which + is what was actually guarding against it. + ### Changed - The lexical fallback no longer extracts a table's trigrams once per column in the database. On a 1,000-table schema the generation stage drops from 17.4s to 2.5s and from 16 GB of allocation to 11 MB. +### Documentation + +- The README said the binary carried four dependencies. It carries six direct + ones and links twenty-five modules at 11 MB — the interactive `setup` guide + arrived in between, and the sentence did not. The cost is now stated with the + reason it was accepted, which is the argument the reader opening `go.mod` is + actually looking for. +- The README now says that the shipped profile default is `pt-br`, and what + passing `--profile en` buys an English schema. Every run already printed the + profile it used; nothing said which one you would get. +- The status badge tracked a hand-written "pre-release" through three releases. + It now reads the latest release from GitHub, so it cannot go stale again. +- "Weak and rejected are reported too" was true of weak and half-true of + rejected: discards are counted on every run, and named by `--include-rejected`. + The sentence now says so. + ## [0.1.2] — 2026-08-13 ### Fixed diff --git a/README.md b/README.md index 8e52f87..aea54bd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- status: pre-release + latest release Go 1.25+ PostgreSQL 13+ read-only @@ -134,8 +134,9 @@ to be resolved before any constraint can be added. plus the `CREATE INDEX CONCURRENTLY` on the child column when it's missing, because an unindexed FK child is a classic delete trap. -**Weak** and **rejected** are reported too, so you never wonder why an obvious-looking -column was ignored. +**Weak** is reported beside them. **Rejected** candidates are counted on every run and named +by `--include-rejected`, each with the score and the reason it fell — so you never wonder why +an obvious-looking column was ignored. Note the first row: `os_servico.resp_tecnico → funcionario.id`. No name-matching heuristic in the world finds that one — `resp_tecnico` looks nothing like `funcionario`. `pgfathom` @@ -303,9 +304,12 @@ privileges, candidates that timed out, schemas not covered — all of it appears coverage block on every run. A clean report means "I looked and it's clean", never "I couldn't look". -**Small dependency tree, on purpose.** Four dependencies in the binary. No cgo. The person -who has to approve running this against production will open `go.mod` first, and we intend -that to be a short read. +**Small dependency tree, on purpose.** Six direct dependencies, twenty-five modules in the +linked binary, 11 MB, no cgo. Four of the six do the work — the driver, the CLI framework, +the TOML reader and `x/sync`. The other two draw the interactive `setup` guide and are +reachable from nothing else; that cost was argued before it was paid, in +[`openspec/project.md`](openspec/project.md). The person who has to approve running this +against production will open `go.mod` first, and we intend that to be a short read. ## How it works @@ -342,6 +346,21 @@ catalog → usage evidence → candidates → scoring → stats prefilte Most schema tools assume English. The databases that need this tool most often aren't. +**The shipped default is `pt-br`, and every run prints which profile it used in its first +line.** That default is not a claim about your database — it is where this tool was built and +what it was built to read. If your schema is English, say so: + +```console +$ pgfathom discover --profile en +``` + +What it costs you if you don't is the plural rules. `categories → category` and `boxes → box` +are in `en` and in no other profile, so under `pt-br` those columns fall through to the +lexical fallback — still found, at roughly half the name weight (0.08 against 0.15 for +`category_id` here), which means they now need the other signals to carry them over the +threshold rather than clearing it on their own. Naming detection, on by default, reads the +affix convention off your schema either way; what it reads is affixes, not grammar. + Affix and plural rules live in TOML, not in Go, so teaching `pgfathom` a new convention is a config file rather than a patch: From e10a44d829ebd9005afd9ef5ca477c45882634c0 Mon Sep 17 00:00:00 2001 From: lvcas-dotcom Date: Tue, 18 Aug 2026 20:09:37 -0300 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20fecha=20a=20se=C3=A7?= =?UTF-8?q?=C3=A3o=200.2.0=20do=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor, não patch: `audit` ganhou uma classe de achado que não tinha, e a descoberta passa a levantar e confirmar relações que a ferramenta descartava. A política declarada no topo do arquivo é que feature em 0.x vai em minor, e um release que muda o que volta de uma execução não deve chegar numerado como se nada tivesse mudado. scripts/release-notes.sh v0.2.0 extrai a seção inteira; a fiação de --release-notes contra o estágio de changelog do goreleaser continua correta. O carimbo de versão foi conferido pelo mesmo caminho de ldflags do release. Falta rodar `make release-check` inteiro, que precisa de goreleaser — o job release-config do CI cobre isso no push. --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57b637..51bf723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ Versions follow [semantic versioning](https://semver.org/spec/v2.0.0.html). Whil the major version is `0`, the command-line surface and the JSON contract may change between minor versions; both are versioned and documented when they do. -## [Unreleased] +## [0.2.0] — 2026-08-18 + +Minor rather than patch: `audit` gained a class of finding it did not have, and +discovery raises and confirms relationships this tool used to drop. A release +that changes what comes back from a run should not arrive numbered as if +nothing had. ### Added @@ -195,6 +200,7 @@ These held from the first release and are covered by tests: - **Silence is never absence.** Every report states what was not analysed, so "nothing found" can never be confused with "nothing was looked at". +[0.2.0]: https://github.com/lvcas-dotcom/pgfathom/releases/tag/v0.2.0 [0.1.2]: https://github.com/lvcas-dotcom/pgfathom/releases/tag/v0.1.2 [0.1.1]: https://github.com/lvcas-dotcom/pgfathom/releases/tag/v0.1.1 [0.1.0]: https://github.com/lvcas-dotcom/pgfathom/releases/tag/v0.1.0