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 @@
-
+
@@ -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