Métodos — como o DataVault calcula cada número
Este documento consolida a metodologia estatística do DataVault: o que cada número mostrado na interface mede, a fórmula, as constantes (com a referência ao código), os pressupostos, o que é exato e o que é estimado ou amostrado, e as limitações conhecidas. É a resposta citável a «como é calculado o score, a anomalia, o duplicado?» — em RFP, em due diligence e na própria app (página Ajuda → «Como calculamos», com um ⓘ junto a cada número).
O documento não pode envelhecer em silêncio: backend/tests/test_methods_doc.py
importa cada constante das tabelas abaixo e falha quando o valor escrito diverge do
código, quando a constante sai do ficheiro citado, quando uma referência cita um
ficheiro que não existe, ou quando uma secção deixa de existir na página Ajuda. As
linhas citadas estão alinhadas com o código do último commit deste documento; se
outro cartão só deslocar linhas, o teste avisa sem falhar e
python scripts/sync_methods_refs.py --fix (em backend/) realinha-as pelo diff.
Âmbito e convenções
- Identificadores, ficheiros e constantes em inglês, tal como no código; texto em
português europeu. Nas tabelas de constantes os decimais usam ponto (
0.85) para o teste os ler; no texto usam vírgula (0,85). - Cada tabela de constantes tem o formato fixo
| constante | valor | referência |, em que a constante é o caminho Python completo (app.…) e a referência ébackend/app/…/ficheiro.py:linha. Constantes com_inicial são privadas ao módulo mas igualmente verificadas. - Três naturezas de número, usadas na tabela final:
- exato — agregação sobre a tabela inteira na fonte (pushdown SQL) ou cálculo determinístico sobre todos os registos envolvidos;
- estimado — algoritmo aproximado com erro conhecido ou publicado pelo
fornecedor (HLL,
APPROX_PERCENTILE,NTILE), ou heurística declarada; - amostrado — calculado sobre as linhas que o scan leu (ver
Amostragem). A interface di-lo em cinco sítios: o issue de
duplicados («% da amostra»,
impact_json.samplede o método da amostra), a dimensão «unicidade» do score («≈» e «Estimado sobre amostra … de N linhas», cartões #294 e #317), as distribuições sem pushdown (badge «Estimado por amostra»), o perfil (selo com o método e o tamanho da amostra de linhas, cartão #317) e o perfil de um ficheiro que passou o teto de leitura (badge «perfil sobre amostra de N linhas»; a contagem de linhas desse ficheiro é exata, ou diz «contagem de linhas estimada» quando foi extrapolada). Os issues das regraspattern,required_fields,valid_identifierein_reference_setdizem sobre que amostra foram avaliadas (cartão #317); o resultado da regra em si (passou/falhou) não leva marca.
- As profundidades de perfil (
basic|standard|deep) condicionam o que é calculado; os limiares configuráveis vivem embackend/app/config/settings.pye são indicados como tal. Nenhum método usa um LLM: tudo aqui é determinístico.
Amostragem
O DataVault não copia dados. Os agregados do perfil (contagens, nulos, distintos, mínimo/máximo, média, desvio) são calculados na fonte por SQL. Há, porém, análises que precisam de linhas cruas, e essas linhas vêm de uma leitura limitada.
O que se lê. BaseConnector.sample_rows(table, limit=ROW_FETCH_LIMIT, seed, row_count) (backend/app/domain/scans/service.py:1657-1680, cartão #317) devolve
as linhas e o método que as escolheu (RowSample em
backend/app/infra/connectors/base.py:498). Só SELECTs, sempre limitados pelo
teto de amostragem da fonte (cartão #252). O método depende do tamanho da tabela
(o row_count do perfil) e do dialeto (SQLAlchemyConnector._sample,
backend/app/infra/connectors/sql.py:946-1036):
| Situação | Método | SQL (resumo) | Determinístico |
|---|---|---|---|
tabela com ≤ limit linhas |
full |
SELECT … ORDER BY chave LIMIT n — a tabela inteira |
sim |
PostgreSQL até sample_full_scan_max_rows |
bernoulli |
TABLESAMPLE BERNOULLI (p) REPEATABLE (s) |
sim |
| PostgreSQL acima desse teto | system |
TABLESAMPLE SYSTEM (p) REPEATABLE (s) (blocos inteiros) |
sim |
| BigQuery | system |
TABLESAMPLE SYSTEM (p PERCENT) |
não (sem REPEATABLE) |
| Snowflake | system |
SAMPLE SYSTEM (p) SEED (s) |
sim |
| Databricks | bernoulli |
TABLESAMPLE (p PERCENT) REPEATABLE (s) (linha a linha) |
sim |
| MySQL/MariaDB, SQLite, SQL Server com chave primária, até ao teto | hash |
WHERE hash(chave#s) mod 10⁶ < k ORDER BY hash(chave#s) mod 10⁶, chave LIMIT n |
sim |
| sem chave primária, vista, dialeto sem amostragem, ou amostra nativa recusada | head |
ORDER BY 1.ª coluna ordenável LIMIT n (sem ORDER BY acima do teto ou num warehouse) |
só com ORDER BY |
| janela do perfil incremental (INCR), qualquer dialeto | hash com chave primária, senão head |
WHERE janela AND hash(chave#s) mod 10⁶ < k …, ou WHERE janela ORDER BY coluna temporal, chave LIMIT n — nunca TABLESAMPLE |
sim |
| BigQuery com a guarda de custo degradada (cartão #293) | system |
TABLESAMPLE SYSTEM (p PERCENT) LIMIT n com o p do plano de custo, numa só query |
não |
| ficheiros (CSV, GCS, S3) | stride |
uma linha em cada k das que o perfil lê, a partir de s mod k |
sim |
- Fração. Na amostra nativa pede-se
p = 1,5 × limit ÷ linhas(em %) e umLIMITde rede de2 × limit; o excesso corta-se em Python pelas linhas de menor hash (rank_rows,backend/app/infra/connectors/base.py:604-617) — oLIMITsozinho cortava sempre o fim do último bloco lido. Na amostra por hash,k/10⁶ ≈ 1,5 × limit ÷ linhase a ordenação pelo próprio hash guarda osnmenores: uma amostra uniforme de exatamentenlinhas. Ofractiongravado é linhas devolvidas ÷ linhas da tabela (semfractionna janela do INCR, onde só a janela foi contada). - Semente.
stable_seed(dataset.id)(blake2b, em [0, 2³¹), o intervalo doSEEDdo Snowflake;backend/app/domain/scans/sampling.py:22-24): o mesmo dataset lê a mesma amostra de scan para scan, e os clusters de duplicados e as violações de padrão deixam de abrir e fechar sem os dados mudarem. Um scan manual comresample(APIPOST /scans, caixa «Nova amostra» na app) usa outra semente só nesse scan. Na amostra por hash a semente entra no texto antes do hash: sementes vizinhas dão amostras independentes. - Recursos. Uma vista ou foreign table no PostgreSQL recusa
TABLESAMPLE: a query falha, fica no log (sample_native_fallback) e a amostra passa ahead. O mesmo quando os blocos amostrados vêm vazios (tabela pequena no BigQuery). As tabelas acima desample_full_scan_max_rowsnão levam leituras da tabela inteira (BERNOULLI, hash,ORDER BY): ficamsystemouheadsem ordem. Um erro que nenhum recurso resolve (o teto de bytes por query do BigQuery) não cai parahead: sobe com a mensagem do teto. - Janelas. O
TABLESAMPLEcorre antes doWHERE. Na janela do perfil incremental, um BERNOULLI leria a tabela inteira em cada re-scan, e um SYSTEM calibrado pelas linhas da janela devolveria quase nada. Por isso, com um filtro de linhas não há amostra nativa: amostra-se pelo hash da chave, ou pelas primeiras linhas pela coluna temporal, que tem o índice da janela. O custo fica no da janela, como o dos agregados. A janela de partições do BigQuery (cartão #293) é outra coisa: poda as partições antes doTABLESAMPLE. Aí a amostra calibra-se pelas linhas da janela e nunca cobre a tabela. - O que fica gravado.
profile_results.metrics_json["sample"]da tabela ={method, rows, fraction, seed, deterministic, covers_table}; a interface mostra-o num selo junto às colunas. O issue de duplicados e os issues das regras avaliadas sobre a amostra dizem o método e o tamanho («Análise sobre amostra aleatória de 5000 linhas (TABLESAMPLE BERNOULLI; semente …; 0,05 % da tabela)»), e oimpact_json/details_jsonlevam o mesmosample. - Amostra ou tabela inteira. Decide-se pelo
covers_tabledo conector (a leitura acabou antes do limite), não por «a leitura encheu o limite»: um TABLESAMPLE que devolve 4 800 linhas de 10 M fica abaixo do limite e não é a tabela (_duplicate_basis,backend/app/domain/scans/service.py:1643-1654;rows_cover_table,backend/app/domain/scans/unique_keys.py:90-101).
Continua a não ser uma amostra estratificada, e uma amostra system apanha
blocos inteiros (enviesa quando a tabela está agrupada fisicamente). O head
sem ORDER BY são as primeiras linhas que a fonte devolver, sem aleatoriedade.
Nos ficheiros, o passo cobre as linhas que o perfil leu (as primeiras
csv_profile_max_rows ou ROW_LIMIT), não o ficheiro inteiro quando ele passa
o teto.
Quando se lê. Só quando há regras que exigem linhas (pattern,
required_fields, accuracy, max_event_age com coluna de evento;
_rule_needs_rows em backend/app/domain/scans/service.py:2664-2678), quando
há colunas de texto para detetar duplicados (service.py:1068), ou quando o
conector não suporta o pushdown do perfil profundo (service.py:1725-1730). A regra unique conta na
fonte (abaixo) e só usa as linhas lidas quando
são a tabela inteira (service.py:1101-1106).
Perfil de ficheiros. Nos ficheiros não há pushdown: o próprio perfil é
calculado em memória sobre o que se leu. O CSV local lê até csv_profile_max_rows
linhas e csv_profile_max_bytes bytes (settings); o GCS e o S3 leem as primeiras
ROW_LIMIT = 100 000 linhas. Quando a leitura para antes do fim, o perfil da tabela
diz sampled e sample_rows, e as métricas das colunas (nulos, distintos, média,
desvio, amostras de valores) são amostradas — cada coluna leva from_sample. A
contagem de linhas não: em GCS/S3 o row_count é do ficheiro inteiro
(backend/app/infra/connectors/object_storage.py:403-416). Num Parquet vem do
footer (num_rows, sem ler dados) e, quando todos os row-groups têm estatísticas,
os nulos e o mín/máx (inteiros, floats, timestamps, datas) de cada coluna são também
exatos — a coluna lista-os em exact_metrics; num CSV vem de uma segunda passagem
em streaming com csv.reader até object_storage_count_max_bytes — teto conferido
a cada leitura do parser (nunca por linha: uma linha gigante também para), nos
bytes do ficheiro e, num .gz, também nos bytes descomprimidos entregues ao parser
(um .gz muito compressível expande milhares de vezes). A primeira medida que o
passa dá fim de ficheiro ao parser; a contagem é extrapolada pela razão observada
(linhas contadas × tamanho do ficheiro ÷ bytes do ficheiro consumidos — num .gz,
os comprimidos que o inflate consumiu, medidos sem a leitura antecipada do gzip)
e marcada row_count_estimated (badge «contagem de linhas estimada»). Como a
amostra já se leu, um .gz truncado ou corrompido, ou uma célula acima do limite
de campo, mais à frente não chumba o perfil: a contagem para aí e fica estimada da
mesma forma. Ficheiros abaixo de ROW_LIMIT não fazem segunda leitura. A contagem
sobe o limite de campo do módulo csv para 64 Mi caracteres (o pandas da amostra
não tem limite; é global ao processo e fica subido).
Base das métricas amostradas. Num perfil amostrado, distintos, média, zeros e
amostras de valores são das linhas lidas, e o row_count é do ficheiro inteiro.
Quem compara uns com o outro usa a base da amostra (TableProfile.basis_rows,
sample_column e basis_non_null em backend/app/infra/connectors/base.py), nunca
o row_count. Sem isto, «linhas − nulos − distintos» dava 150 000 repetidos numa
chave única de 250 000 linhas. Assim, a regra unique de uma coluna avaliada pelo
perfil conta os repetidos sobre os não-nulos da amostra e diz sample_rows nos
detalhes: repetidos na amostra chumbam a regra, e a ausência deles deixa-a
indeterminada (regra unique). Num Parquet com nulos exatos do footer, a coluna guarda os da amostra em
sample_null_count, que também volta no perfil reconstruído das regras e do
histórico. A mesma base vale para as sugestões de unicidade (insight e regras por
omissão), o gerador de regras, os alertas do perfil, os tipos semânticos, a escolha
de colunas das distribuições e das dimensões e o gerador de dados sintéticos. A
estimativa da regra no browser também a usa.
Desvio-padrão. É sempre o populacional. No SQL usa as funções nativas
STDDEV_POP/STDEVP. Nos ficheiros e no SQLite a soma é exata em inteiros e usa
Welford sobre os valores deslocados no resto (PopulationStddev em
backend/app/infra/connectors/profiling.py). Nunca se usa E[x²] − E[x]² em
vírgula flutuante, que se cancela em valores grandes (epoch-millis, IDs, cêntimos):
numa coluna com dispersão 100 devolvia 0,0 em vez de 28,9.
O que herda a amostra.
| Número | Como herda | Marca na interface |
|---|---|---|
| Duplicados exatos e fuzzy | clusters só entre as linhas lidas | impact_json.sampled=true, impact_json.sample e «% da amostra» no issue, com o método e o tamanho («Análise sobre …») |
| Dimensão «unicidade» do score | numerador = linhas duplicadas na amostra; denominador = as mesmas linhas lidas, pela regra do issue de duplicados (service.py:1643-1654, cartão #294) → é a taxa da amostra, não a da tabela |
«≈» e «Estimado sobre amostra … de N linhas» na dimensão, no detalhe do dataset (frontend/src/pages/DatasetDetalhe.tsx); score_history[].dimensions_meta na API, com sample_method |
Regras pattern e required_fields (e max_event_age com coluna de evento) |
avaliam as linhas lidas; sem linhas, pattern cai nos sample_values (backend/app/domain/quality/rules.py:435-447) |
o issue diz «Avaliada sobre …» com o método e o tamanho; details_json.sample no resultado |
Regras valid_identifier e in_reference_set |
nunca recebem linhas no scan (service.py:1101, service.py:2209-2217): avaliam sempre os ≤ 20 sample_values do perfil (backend/app/domain/quality/rules.py:807-844) — medem a amostra de valores, não as linhas. Exceção (cartão #272): uma in_reference_set com uma lista do registo do workspace (params.reference_list) recebe as linhas lidas (_rule_needs_rows em backend/app/domain/scans/service.py), conta os valores fora da lista e, com params.label_column, os rótulos de cada código |
o issue diz «Avaliada sobre N valores distintos da amostra de valores do perfil, tirados de …»; com uma lista do registo, como na pattern: «Avaliada sobre …» com o método e o tamanho da amostra de linhas, e details_json.sample no resultado (rule_sample_note em backend/app/domain/scans/sampling.py) |
| Quantis, histogramas, correlações e co-nulidade | só em fontes sem pushdown (ficheiros, APIs): method="sample", estimated=true (backend/app/domain/quality/distributions.py:146) |
badge «Estimado por amostra · N linhas» (frontend/src/components/ProfileDistributions.tsx:112-123) |
| Impacto financeiro | usa o número de linhas duplicadas (amostrado) | só a do issue de duplicados; os dashboards de gestão dizem «estimados» sem referir a amostra |
Amostra de valores por coluna. O perfil guarda até sample_limit=20 valores
distintos não nulos por coluna, tirados em Python, pela ordem de leitura, de
uma amostra de VALUE_SAMPLE_ROWS = 1 000 linhas das colunas perfiladas
(o mesmo _sample, com a semente do nome da tabela;
SQLAlchemyConnector._value_samples, backend/app/infra/connectors/sql.py:1651-1714).
Até ao cartão #317 era um SELECT DISTINCT col WHERE col IS NOT NULL LIMIT 20 sem
ORDER BY por coluna: 120 colunas eram 120 queries por tabela (no BigQuery, 120
leituras faturadas da coluna inteira) e os valores mudavam de scan para scan.
Agora o perfil SQL faz 2 + ⌈colunas/10⌉ queries: contagem, agregados por lote de
10 e a amostra. Uma coluna volta a pedir os valores à parte (SELECT DISTINCT … ORDER BY col LIMIT 20) em dois casos. O primeiro é a coluna quase toda NULL, em
que a amostra viu menos de 20 valores não nulos mas a tabela tem mais: assim a
deteção de PII não perde colunas esparsas. O segundo são as poucas categorias
(distinct_count ≤ 20) que a amostra não viu todas: uma categoria abaixo de
~0,1 % fica muitas vezes fora de 1 000 linhas, e a proposta de valores aceites
(backend/app/domain/rules/generation.py:342) só se faz com o domínio inteiro
(len(values) == distinct_count). Com a guarda de custo do BigQuery degradada não
há consultas por coluna, porque cada uma leria a coluna inteira: os valores vêm só
da query TABLESAMPLE do plano. A amostra usada fica em
metrics_json["value_sample"]. Estes valores alimentam os tipos
semânticos, a deteção heurística de PII, as máscaras de formato e o contexto da IA.
Não são representativos da distribuição: um tipo semântico com valid_pct=1.0 diz
que 20 distintos casaram, não que 100 % das linhas casam.
Outras leituras limitadas. A qualidade de rótulos lê até
label_quality_max_rows linhas com a mesma amostra por dialeto e a mesma semente
do dataset (cartão #317; o resumo e o issue guardam-na em sample) e estratifica
em memória; o data-diff lê até diff_max_rows por lado com o método de
amostragem que declara; a correspondência entre datasets lê até
matching_max_rows por lado com LIMIT (as primeiras linhas, não aleatório: uma
amostra independente de cada lado deixaria de fora quase todos os pares).
| constante | valor | referência |
|---|---|---|
app.domain.scans.service.ROW_FETCH_LIMIT |
5000 | backend/app/domain/scans/service.py:242 |
app.infra.connectors.sql.VALUE_SAMPLE_ROWS |
1000 | backend/app/infra/connectors/sql.py:2195 |
app.infra.connectors.sql._SAMPLE_OVERSAMPLE |
1.5 | backend/app/infra/connectors/sql.py:3243 |
app.infra.connectors.sql._SAMPLE_SAFETY |
2 | backend/app/infra/connectors/sql.py:3244 |
app.infra.connectors.sql._HASH_SAMPLE_MODULUS |
1000000 | backend/app/infra/connectors/sql.py:3246 |
app.infra.connectors.base.SAMPLE_SEED_MODULUS |
2147483648 | backend/app/infra/connectors/base.py:618 |
app.config.settings.Settings.model_fields["sample_full_scan_max_rows"].default |
25000000 | backend/app/config/settings.py:887 |
app.infra.connectors.object_storage.ROW_LIMIT |
100000 | backend/app/infra/connectors/object_storage.py:112 |
app.config.settings.Settings.model_fields["object_storage_count_max_bytes"].default |
512000000 | backend/app/config/settings.py:862 |
app.config.settings.Settings.model_fields["label_quality_max_rows"].default |
50000 | backend/app/config/settings.py:1080 |
app.config.settings.Settings.model_fields["diff_max_rows"].default |
50000 | backend/app/config/settings.py:1075 |
app.config.settings.Settings.model_fields["matching_max_rows"].default |
50000 | backend/app/config/settings.py:1100 |
Score de qualidade
O que mede. Uma nota 0–100 por dataset e por scan, média ponderada de cinco
dimensões, cada uma também em 0–100 (backend/app/domain/quality/scoring.py:115-170).
Fórmula.
score = Σ_d w_d · dim_d d ∈ {completude, unicidade, validade, atualidade, consistencia}
Cada dimensão é limitada a [0, 100] e arredondada a 1 casa decimal antes de entrar na soma; o score final é de novo limitado e arredondado a 1 casa. Os pesos somam 1.
| Dimensão | Fórmula | Natureza |
|---|---|---|
| Completude | 100 − média simples de null_pct sobre todas as colunas (scoring.py:229-234). Todas as colunas pesam o mesmo, seja uma chave ou um campo opcional. Sem colunas → 100. |
exato |
| Unicidade | 100 · (1 − duplicate_row_count / base) (scoring.py:237-246). duplicate_row_count = Σ (tamanho do cluster − 1) sobre os clusters exatos + fuzzy (service.py:1201), calculados sobre a amostra e sobre ≤ 4 colunas de texto. base = as linhas lidas quando são amostra — o conector não leu a tabela inteira (covers_table, cartão #317) ou o perfil é de um ficheiro amostrado, a mesma regra do issue de duplicados (service.py:1643-1654, cartão #294) —; senão o row_count da tabela. base ≤ 0 → 100. Com amostra, quality_scores.dimensions_meta_json guarda {"unicidade": {"estimated": true, "sample_size", "duplicate_rows", "sample_method"}}. |
amostrado (marcado na interface) |
| Validade | 100 · regras passadas / regras avaliadas entre as regras de validade: pattern, range, schema_conformity, required_fields, max_malformed_pct, valid_identifier, in_reference_set (scoring.py:29-40, 239-244). Sem regras destes tipos → 100 (a ausência de regras não penaliza). |
por regra: range/schema_conformity/max_malformed_pct sobre o perfil (exato); pattern/required_fields sobre as 5 000 linhas lidas (amostrado); valid_identifier/in_reference_set sobre os 20 sample_values (amostrado) |
| Atualidade | 100 quando a regra freshness/max_event_age passa ou não existe. Regra falhada: 100 · limite / observado (idade em horas; decai com a razão observado/limite, não linearmente com o tempo); 50 quando a idade não é mensurável. Com várias regras vale a pior (scoring.py:257-277). |
exato |
| Consistência | min(drift, regras). drift = 100 − 15 · avisos − 30 · críticos do drift estrutural deste scan (coluna removida, tipo alterado) − Σ pontos dos issues abertos (scoring.py:280-296). Desde o cartão #351, o drift estatístico (nulos, linhas, distribuição) e as anomalias contam pelo issue que abrem: cada issue schema_drift ou anomaly do dataset, aberto ou reconhecido, desconta base · max(0, 1 − idade / K), com base 15 (aviso) ou 30 (crítico), idade = scans do dataset desde o que o detetou pela última vez e K = DATAVAULT_CONSISTENCY_DECAY_SCANS (scoring.py:173-199, backend/app/domain/quality/consistency_service.py:21-66). O score calcula-se depois do upsert dos issues (service.py:1602-1636): o finding deste scan já é um deles, com idade 0. Um issue ignorado ou resolvido não desconta. column_added é info e não penaliza. regras = 100 · passadas / avaliadas entre as regras expression, custom_sql (cartão #265) e referential (cartão #164) (scoring.py:45). Sem essas regras, fica só o drift. |
exato |
Pressupostos e limitações.
- Regras
not_null,unique,row_count_min,freshnesspassada e as de streaming fora da lista de validade não entram no score (exceção:max_event_age, que entra na atualidade com a mesma fórmula dafreshness,scoring.py:42); geram issues. Asexpression/custom_sql/referentialentram na consistência (acima). - Com amostra, a unicidade é a taxa das linhas lidas, não a da tabela: numa
tabela de 1 M de linhas, 500 linhas redundantes nas 5 000 lidas dão 90,0. Até
ao cartão #294 a base era o
row_countda tabela inteira e davam 99,95 (arredondado, 100,0): a dimensão ficava perto de 100 qualquer que fosse a taxa real. A amostra é a do método do dialeto (cartão #317): aleatória nos motores comTABLESAMPLE/SAMPLEou com chave primária, as primeiras linhas nos restantes — aí, se os duplicados se concentram no início ou no fim da tabela, a estimativa erra nesse sentido. Não há intervalo de confiança — por isso a interface diz «estimado». Os scores gravados antes do cartão #294 não são recalculados; em tabelas grandes com duplicados, a série desce no primeiro scan depois da mudança. No primeiro scan depois do cartão #317 a amostra muda uma vez (das primeiras linhas para a do método), e com ela os clusters e a unicidade; daí em diante a semente fixa mantém-na. - Consistência com memória, mas com decaimento (cartão #351). Uma queda de 60 % das linhas abre
um issue crítico: a consistência dá 70 no dia D, 76 em D+1, 82 em D+2 e só volta a 100 ao fim
de K = 5 scans sem nova deteção, ou no primeiro scan depois de alguém resolver ou ignorar o
issue. Até ao cartão dava 70 em D e 100 em D+1, com o issue ainda aberto. O decaimento é um
compromisso para não penalizar para sempre: ao fim de K scans o issue continua aberto e
quality_scores.dimensions_meta_json["consistencia"]lista-o com idade K e 0 pontos. O score deixa de o contar, mas o registo diz que continua por resolver. Um problema que o scan volta a detetar (uma anomalia que persiste, um drift de nulos que se repete) repõe a idade a 0, porque o upsert reescreveIssue.scan_id: desconta inteiro enquanto se repetir. Um drift de linhas compara com o scan anterior, por isso, depois da queda, os scans seguintes já não o detetam e o desconto decai. A idade conta-se até K, sobre os K scores anteriores do dataset. O drift estrutural (schema_change) continua a contar só no scan que o deteta: o schema novo passa a ser a referência. Os scores gravados antes do cartão não são recalculados. Nos datasets com issues abertos, o score desce a partir do primeiro scan depois da atualização e fica mais estável. - Os pesos são fixos por produto (não configuráveis por workspace).
- Na interface, o círculo do score é verde a partir de 90, âmbar entre 70 e 89 e
vermelho abaixo de 70 (
frontend/src/components/ScoreBadge.tsx:11-15); as faixas são convenção visual, não limiares estatísticos.
| constante | valor | referência |
|---|---|---|
app.domain.quality.scoring.DIMENSION_WEIGHTS["completude"] |
0.25 | backend/app/domain/quality/scoring.py:22 |
app.domain.quality.scoring.DIMENSION_WEIGHTS["unicidade"] |
0.25 | backend/app/domain/quality/scoring.py:23 |
app.domain.quality.scoring.DIMENSION_WEIGHTS["validade"] |
0.20 | backend/app/domain/quality/scoring.py:24 |
app.domain.quality.scoring.DIMENSION_WEIGHTS["atualidade"] |
0.15 | backend/app/domain/quality/scoring.py:25 |
app.domain.quality.scoring.DIMENSION_WEIGHTS["consistencia"] |
0.15 | backend/app/domain/quality/scoring.py:26 |
app.domain.quality.scoring._DRIFT_WARNING_PENALTY |
15.0 | backend/app/domain/quality/scoring.py:46 |
app.domain.quality.scoring._DRIFT_CRITICAL_PENALTY |
30.0 | backend/app/domain/quality/scoring.py:47 |
app.domain.quality.columns.MAX_DUPLICATE_TEXT_COLUMNS |
4 | backend/app/domain/quality/columns.py:16 |
app.config.settings.Settings.model_fields["consistency_decay_scans"].default |
5 | backend/app/config/settings.py:1002 |
Decomposição do Δscore
O que responde. «O score caiu 12 pontos — porquê?» (cartão #318). Entre dois
scans do mesmo dataset, GET /datasets/{id}/score/explain?from=&to= decompõe a
variação em dimensões e, dentro de cada uma, em drivers com pontos de score
(já ponderados, com sinal). Por omissão compara os dois últimos scans concluídos.
A função é pura (backend/app/domain/quality/score_explanation.py); o serviço
só lê o que o scan gravou.
Nível 1 — exato, por dimensão. Sobre os valores gravados
(quality_scores.dimensions_json e score), nunca recalculados:
Δscore = Σ_d w_d · (dim_d(depois) − dim_d(antes)) + arredondamento do score
O arredondamento do score final (1 casa) é o único resto deste nível e vai para
unexplained_points (|resto| ≤ 0,1 com as dimensões gravadas).
Nível 2 — drivers dentro de cada dimensão. Onde o scoring.py é linear, a
soma fecha; onde não é (min(), limite a [0, 100], arredondamento de cada
dimensão), o que sobra fica no unexplained_points dessa dimensão — a soma
nunca é forçada.
| Dimensão | Driver | Pontos |
|---|---|---|
| Completude | column_nulls: uma coluna presente nos dois scans cujo null_pct mudou |
−w · (null_depois − null_antes) / n_depois |
| Completude | column_set_changed: colunas perfiladas que entraram ou saíram |
o resto da dimensão (a fórmula Δnull/n só fecha com as mesmas colunas) |
| Unicidade | duplicates: a taxa de linhas redundantes |
w · Δunicidade |
| Unicidade | uniqueness_basis_changed: um dos scans mediu sobre amostra e o outro sobre a tabela inteira (cartão #294) |
w · Δunicidade, com a nota de que a base mudou |
| Validade | rule_failing / rule_passing: regra que mudou de passou ↔ falhou |
± w · 100 / n_depois |
| Validade | rule_set_changed: regras criadas, apagadas, desativadas ou agora indeterminadas |
o resto da dimensão — dito mesmo quando vale 0 pontos |
| Atualidade | freshness: a regra que fixa o min() agora (ou que o fixava, se recuperou) |
w · Δatualidade |
| Consistência | drift: cada achado de drift estrutural (tipo, coluna, severidade); nos scores anteriores ao cartão #351, cada achado de drift, que aparece ou desaparece entre os dois scans |
∓ w · 15 (aviso) ou ∓ w · 30 (crítico) por achado |
| Consistência | consistency_issue: um issue aberto cujo desconto mudou (cartão #351) — abriu (opened), decaiu (decaying), foi detetado de novo (detected_again) ou deixou de estar aberto (gone), com o issue_id e as idades |
−w · (pontos depois − pontos antes) |
| Consistência | regras expression/custom_sql |
como na validade, quando são elas o ramo ativo do min() |
| Consistência | consistency_branch_changed: o min(drift, regras) trocou de ramo |
w · Δconsistência |
| Qualquer | dimension_unrecorded: a dimensão não existe num score antigo (conta como 100) |
w · Δdimensão |
Pressupostos e limitações.
- O drift reconstrói-se do que o scan gravou em
dimensions_meta_json["drift"](tipo, coluna e severidade dos achados que contaram), não dos issues: o upsert reescreve oscan_iddos issues abertos e o drift estrutural junta-se num sóschema_change. Os scores anteriores ao cartão #318 não têm esse registo: a variação da consistência fica emunexplained_pointscom a notadrift_not_recorded, em vez de se inventar uma causa. A API pública não expõe essa chave (dimensions_metacontinua só com estimativas). - Desde o cartão #351,
dimensions_meta_json["drift"]só leva o drift estrutural, edimensions_meta_json["consistencia"]leva{decay_scans, issues: [{issue_id, type, subtype, severity, age_scans, base_points, points}]}, os issues que descontaram nesse scan (backend/app/domain/quality/score_explanation.py:201-219). Entre um score anterior e um posterior ao cartão, os achados antigos aparecem como «deixou de se repetir» e os issues como novos. A soma fecha, e a notaconsistency_model_changeddiz que o cálculo mudou. - Os resultados indeterminados (cartão #291) ficam fora, como no score.
observed/thresholdestão gravados como texto: só valores numéricos finitos entram na frase da atualidade.- A frase em pt-PT de cada driver (
copy) é determinística e serve os relatórios; a interface escreve-a nos 3 idiomas a partir dekind+params. Os relatórios do engenheiro e do gestor de um dataset citam os 3 maiores drivers do último Δ, sem LLM.
Scorecard por dimensão (DAMA / ISO 8000)
O que mede. Ao lado do score composto (que não muda), uma nota 0–100 por
dimensão da DAMA — completude, unicidade, validade, atualidade, consistência,
razoabilidade, exatidão e integridade — por dataset e por scan
(backend/app/domain/quality/dimension_scorecard.py, cartão #264).
Fórmula.
score_d = 100 · checks aprovados em d / checks que correram em d (1 casa decimal)
sem checks em d → «não medido» (null) — nunca 100
Um check é uma regra avaliada, uma família de drift com perfil anterior, uma métrica
de anomalia com histórico suficiente, a deteção de duplicados sobre as linhas lidas
ou um monitor automático já ativo. Cada um conta numa só dimensão, pelo mapa
backend/app/domain/quality/dimensions.json (escolhas em COBERTURA_QUALIDADE.md
§«Scorecard por dimensão»). No domínio e no workspace, a média de cada dimensão
entra só com os datasets medidos nela e mostra «medidos / total».
Pressupostos e limitações.
- Todos os checks pesam o mesmo: uma regra
infofalhada conta tanto como umacritical. O score diz «quantas verificações passaram», não «quão grave é». - As diferenças face ao composto são de propósito:
required_fieldsconta na completude (no composto, na validade) e as regrasnot_null,uniqueerow_count_min, que o composto ignora, aqui contam. - A integridade fica «não medido» sem uma regra
referential. A exatidão fica «não medido» sem uma regraaccuracyavaliada (cartão #266) nem uma auditoria humana concluída: sem checks de exatidão no último scan, a auditoria mais recente preenche a dimensão (backend/app/domain/quality/dimension_scorecard_service.py:272-282). - O histórico começa no primeiro scan com o scorecard (sem backfill).
Regras de consistência por pushdown SQL (expression, custom_sql)
O que mede. Quantas linhas da tabela violam uma condição entre colunas da mesma linha
(expression: data_fim >= data_inicio, total = soma(subtotal, iva), se pais = 'PT' então nif válido) ou devolve um SELECT do cliente (custom_sql: as linhas violadoras, como um teste
singular do dbt). Cartão #265; SQL em backend/app/domain/quality/sql_rules.py.
Fórmula.
violações = COUNT(*) das linhas em que a condição é FALSA (ou que o SELECT devolve)
— contado na fonte, sobre a tabela inteira, até max_counted
passa ⇔ violações ≤ max_violations (0 por omissão)
Pressupostos e limitações.
É exato: nunca a amostra. A contagem para em
max_counted(1 000 000 por omissão,settings.sql_rule_max_counted); acima disso o resultado dizcount_cappede o número é um mínimo.Semântica do
dbt_utils.expression_is_true: uma condição desconhecida (NULL) não é violação — a completude é danot_null.null_as_violationconta-as também.soma(a, b)trata NULL como 0, como oSUMdo SQL. Igualdades entre decimais precisam de tolerância explícita (abs(total - soma(a, b)) <= 0.01).válido(col, tipo)refaz em SQL portável a regra do registo de identificadores (quality/identifier_sql.py, cartões #265 e #517): forma e dígito de controlo e, no NIF, o prefixo e a forma escrita em 9 seguidos ou 3-3-3. Nulos e vazios não contam. Os tipos:nifeniss(PT);cpfecnpj(BR). No CNPJ alfanumérico, cada carácter vale ASCII − 48, lido pela posição numa tabela literal: o SQLite não temASCII()e a guarda do SQL não a deixa passar;dnienie(ES), com a letra tirada porSUBSTRda tabela mod 23;nhs(GB),ahveuid(CH).
O kind do registo também serve (
válido(doc, 'br_cpf')). Cada CAST a inteiro fica atrás do CASE que confirma a forma: um valor com lixo conta como violação e não faz falhar a query. No SQL só saem o espaço, o ponto, o hífen e a barra. Uma tabulação ou uma quebra de linha a meio do valor fazem dele uma violação, embora o Python as tire. IBAN, NIB, CC, Luhn e os outros identificadores do registo (cartão #514) ficam navalid_identifier(amostrada).Fontes sem SQL (ficheiros, APIs, streams): a regra fica «não avaliável» e conta como falhada.
O SQL que corre é regenerado pelo sqlglot e relido antes de sair do domínio: se a releitura não der a mesma consulta, com as mesmas tabelas e o mesmo texto, a regra é recusada (
unstable_sql). Nocustom_sql, literais com escapes (E'…',U&'…') e de bytes, bits ou hex são recusados (literal_form): o valor que se compara é sempre o que o utilizador escreveu.Os exemplos (até 5 linhas) mascaram textos e números longos (
***…123); datas, booleanos, inteiros até 5 dígitos e montantes abaixo de 1 milhão ficam legíveis para o diagnóstico.DETGEN (só em fontes SQL) propõe
fim >= iníciopara pares de datas pelo nome cujos mínimos e máximos do perfil são coerentes, etotal = soma(parcelas) − descontosquando, sem nulos, a média do total difere da soma das médias em menos de 0,5 % (a média é linear: se a igualdade vale linha a linha, vale nas médias; o contrário não, por isso é só uma proposta).
| constante | valor | referência |
|---|---|---|
app.domain.quality.sql_rules.MAX_EXAMPLES |
5 | backend/app/domain/quality/sql_rules.py:89 |
app.domain.quality.sql_rules.MAX_COUNTED_VIOLATIONS |
1000000 | backend/app/domain/quality/sql_rules.py:90 |
app.domain.quality.sql_rules._PLAIN_INT_LIMIT |
100000 | backend/app/domain/quality/sql_rules.py:94 |
app.domain.quality.sql_rules._PLAIN_NUMBER_LIMIT |
1000000.0 | backend/app/domain/quality/sql_rules.py:95 |
app.domain.rules.generation.SUM_MEAN_TOLERANCE |
0.005 | backend/app/domain/rules/generation.py:144 |
app.domain.rules.generation.SUM_ROW_TOLERANCE |
0.01 | backend/app/domain/rules/generation.py:146 |
Regra unique (chaves repetidas)
O que mede. Quantas linhas a mais há numa chave que devia ser única — uma coluna ou
várias (unique do dbt, expect_column_values_to_be_unique do GX, duplicate_count do
SodaCL). Cartão #291; avaliador em backend/app/domain/quality/rules.py:282-357.
Fórmula.
por chave k (todas as colunas da chave preenchidas): n_k = nº de linhas com essa chave
linhas duplicadas = Σ (n_k − 1) chaves repetidas = #{k : n_k > 1}
observado = 100 · linhas duplicadas / Σ n_k passa ⇔ linhas duplicadas = 0
Numa só passagem pela fonte (backend/app/infra/connectors/sql.py:2207-2238):
SELECT SUM(n), SUM(n − 1), SUM(CASE WHEN n > 1 …) FROM (SELECT COUNT(*) AS n … WHERE k1 IS NOT NULL AND k2 IS NOT NULL GROUP BY k1, k2). O HAVING COUNT(*) > 1 passa a
CASE no SELECT de fora para a mesma query dar também a base da percentagem.
Ordem de decisão (backend/app/domain/scans/unique_keys.py, chamada antes de avaliar):
- Contagem na fonte (
details.source = "pushdown"). Fontes SQL: a tabela inteira, na sessão só-de-leitura das regras SQL, comsql_rule_timeout_scomo timeout do statement. CSV, GCS e S3: as mesmas linhas que o perfil leu. - Linhas lidas (
"rows"), só quando são a tabela inteira (a leitura acabou antes do teto de 5 000). - Distintos do perfil (
"profile"), só com uma coluna eCOUNT(DISTINCT)exato. - Nenhum dos três → indeterminada (
details.indeterminate).
Pressupostos e limitações.
- Semântica SQL/dbt/GX: uma linha com NULL numa coluna da chave não conta (um UNIQUE aceita
vários NULL). O Spark (
datavault-profiler) calculaCOUNT(*) − COUNT(DISTINCT chave)sobre as mesmas linhas, que dá o mesmo número (casos de paridade embackend/tests/golden/spark/parity_cases.json). - Indeterminada = sem prova num sentido nem noutro: o
GROUP BYexcedeu o timeout ou foi recusado, a fonte não tem pushdown e a tabela é maior do que a leitura, ou os distintos do perfil foram saltados (acima deprofile_distinct_max_rows) ou estimados por HLL. Grava-se compassed=false(a coluna não tem terceiro estado), mas não abre issue e fica fora do score, das dimensões, do veredito (CI gate), da visão geral, do SLO e da recalibração (backend/app/domain/quality/rule_outcomes.py). O scan registarule_indeterminatecom ids, sem valores. - Ficheiros cortados pelo teto de leitura (
sampled): duplicados nas linhas lidas provam duplicados no ficheiro (exact=false); a ausência deles não prova nada → indeterminada. - Custo: uma query por regra
uniqueem cada scan. No BigQuery fatura só as colunas da chave; no BigQuery e no Databricks não há timeout por sessão (vale o do motor). - Só contagens: as chaves repetidas nunca saem da fonte nem vão para os logs.
| constante | valor | referência |
|---|---|---|
app.config.settings.Settings.model_fields["sql_rule_timeout_s"].default |
60 | backend/app/config/settings.py:1045 |
Drift e mudanças de schema
O que mede. Diferenças entre o perfil deste scan e o do último scan
concluído do mesmo dataset (backend/app/domain/quality/drift.py:40-155; o
anterior é carregado em backend/app/domain/scans/service.py:1112-1113). No primeiro
scan não há drift.
| Tipo | Condição | Severidade |
|---|---|---|
column_added |
coluna nova no perfil | info |
column_removed |
coluna desaparecida | warning |
type_changed |
nome do tipo diferente (comparação sem maiúsculas) | warning |
null_rate_spike |
null_pct − null_pct_anterior > 10 pontos percentuais; > 30 → critical |
warning / critical |
row_count_drop |
queda de > 20 % face ao anterior; > 50 % → critical. Subidas de volume não são drift (ficam para as anomalias). |
warning / critical |
distribution_shift |
quantis, histograma ou quotas por valor de uma coluna mudaram; ver Drift de distribuição | warning / critical |
As comparações são estritas (>), com os null_pct em percentagem 0–100.
As contagens vêm do pushdown, por isso o drift é exato; num perfil incremental
o row_count é a base + janela, não uma recontagem. Em ficheiros GCS/S3 o
row_count é do ficheiro inteiro (ver Amostragem); quando um dos
dois perfis o tem extrapolado (row_count_estimated), a descrição do
row_count_drop termina em «Contagem estimada pelo tamanho do ficheiro».
O null_rate_spike só compara dois perfis com a mesma base. Se um mediu parte
das linhas (sampled, como as primeiras linhas de uma view sem opt-in, cartão #347)
e o outro o objeto inteiro, não há pico de nulos.
Mudanças de schema como monitor. Além do drift do perfil, cada scan compara a
estrutura com a anterior (backend/app/domain/quality/schema_changes.py). Uma
mudança é breaking quando remove uma coluna ou muda o tipo lógico ODCS
(integer, number, boolean, date, string; schema_changes.py:81-87): int
→ bigint não é breaking, int → varchar é. A sugestão de rename (coluna
removida + coluna adicionada) é uma pontuação 0–1 com pesos nome 0,5, tipo 0,3 e
posição 0,2, aceite a partir de 0,55 (schema_changes.py:37-43) — é uma
heurística, apresentada como sugestão e não como facto. Mudanças breaking não
reconhecidas por mais de 7 dias limitam o trust score a 40.
| constante | valor | referência |
|---|---|---|
app.domain.quality.drift.NULL_SPIKE_WARNING_POINTS |
10.0 | backend/app/domain/quality/drift.py:17 |
app.domain.quality.drift.NULL_SPIKE_CRITICAL_POINTS |
30.0 | backend/app/domain/quality/drift.py:18 |
app.domain.quality.drift.ROW_DROP_WARNING_PCT |
20.0 | backend/app/domain/quality/drift.py:19 |
app.domain.quality.drift.ROW_DROP_CRITICAL_PCT |
50.0 | backend/app/domain/quality/drift.py:20 |
app.domain.quality.schema_changes.RENAME_MIN_SCORE |
0.55 | backend/app/domain/quality/schema_changes.py:38 |
app.domain.quality.schema_changes._RENAME_NAME_WEIGHT |
0.5 | backend/app/domain/quality/schema_changes.py:42 |
app.domain.quality.schema_changes._RENAME_TYPE_WEIGHT |
0.3 | backend/app/domain/quality/schema_changes.py:43 |
app.domain.quality.schema_changes._RENAME_POSITION_WEIGHT |
0.2 | backend/app/domain/quality/schema_changes.py:44 |
Drift de distribuição
O que mede. Se a forma de uma coluna mudou entre dois scans: uma mediana que
duplica (cêntimos em vez de euros, outra moeda, outra unidade) ou uma categoria
dominante que troca. Usa o que o profiling já gravou, os quantis e o histograma das
colunas numéricas (Profiling profundo) e as contagens por valor
das dimensões do drill-down por segmento (Anomalias), e não faz nenhuma
query à fonte (backend/app/domain/quality/distribution_drift.py:372-406; chamado em
backend/app/domain/scans/service.py:1157-1165). Só compara colunas que os dois scans
perfilaram. O monitor drift_distribution_shift só conta no scorecard quando comparou
alguma.
Numéricas (distribution_drift.py:409-464):
escala = IQR anterior (p75 − p25); se 0, p95 − p05; se 0, máx − mín; senão 1e-9
deslocação = max_q |q_depois − q_antes| / escala q ∈ {p05, p25, p50, p75, p95}
PSI = Σ_buckets (a − b) · ln(a / b) a, b = quota de cada bucket (vazio → 0,0001)
O PSI só se calcula quando os histogramas dos dois scans têm os mesmos limites (os buckets de largura igual saem do mínimo e do máximo do perfil) e, quando existe, é ele que decide a severidade; sem ele decide a deslocação. Aviso a partir de 0,5×IQR (PSI 0,1), crítico a partir de 1,0×IQR (PSI 0,25). Os limiares do PSI são a convenção de indústria. Os da deslocação são valores iniciais: numa normal, 0,5×IQR é a média a andar 0,67 σ.
Erro declarado dos quantis (distribution_drift.py:198-251). Com quantis
aproximados, uma diferença que cabe no erro do método não é drift. Cada scan tem um
erro de posição e (fração do rank): percentile_cont 0; NTILE(20) 1/20;
APPROX_QUANTILES e APPROX_PERCENTILE 5 %; amostra √(ln(2/0,05) / 2n), a banda DKW
a 95 % com n = linhas da amostra (1,9 % com 5 000 linhas). Decide o método e não a
flag estimated, que o NTILE grava a false. Com E = e_antes + e_depois, não há
finding quando cada quantil novo fica entre Q_antes(p − E) e Q_antes(p + E), em que
Q_antes é a interpolação linear entre (0, mín.), os cinco quantis e (1, máx.) do scan
anterior. Quando há finding, a descrição diz o erro que a deslocação excedeu.
Categóricas (distribution_drift.py:467-525): as dimensões do snapshot de
segmentos (até anomaly_segment_max_dimensions colunas de baixa cardinalidade,
contagens exatas por GROUP BY). Os NULL ficam de fora, porque são o
null_rate_spike.
JS = √( ½·KL(P‖M) + ½·KL(Q‖M) ), M = (P + Q)/2, log base 2 (0 = iguais, 1 = disjuntas)
χ² = Σ (O − E)² / E sobre a tabela 2×k (antes/depois × valor), gl = k − 1
Aviso a partir de JS 0,1, crítico a partir de 0,25. O qui-quadrado só corre quando todas as contagens esperadas são ≥ 5 e, quando corre com p ≥ 0,01, a diferença é acaso para aquele volume e não há finding. Uma troca 60/40 → 40/60 dá JS 0,17: com 1 000 linhas é finding (p < 0,001), com 40 não (p = 0,074). Abaixo das 5 contagens esperadas decide só a distância, e a descrição diz que o χ² não se aplicou.
O que o issue leva. Um issue schema_drift com subtipo distribution_shift por
coluna, na dimensão consistência e com a penalização dos outros drifts no score
composto (−15 por aviso, −30 por crítico). before e after são os cinco quantis ou
as três maiores quotas; o impact_json junta a medida que decidiu, o PSI, a distância
JS, o χ², o método dos dois scans e o erro de posição. Exemplos: «p95 de valor_total
passou de 30,0 para 120,0 (deslocação 9,0×IQR).» e «Categoria dominante de moeda passou
de EUR para USD: EUR 80% → 20%, USD 20% → 80% (distância JS 0,53; χ² p < 0,001).» Às
sugestões da IA chega só a medida, sem quantis nem valores das categorias
(redacted_description em backend/app/domain/quality/drift.py:35).
Regra de regressão (backend/app/domain/rules/regression.py:276-326). Ao resolver
um distribution_shift numérico, a sugestão «nunca mais» é um range sobre o núcleo
p05–p95 anterior ao drift, alargado em metade da amplitude e até ao mínimo e ao máximo
de então, sem negativos quando não os havia. A regra range compara o mínimo e o
máximo de cada scan, por isso só é proposta quando um quantil depois do drift ficava
fora da gama. As categóricas não têm regra.
Limitações.
- Os limites do histograma saem do mínimo e do máximo de cada scan e raramente
coincidem: na maioria dos scans decide a deslocação e não o PSI. Na v2, o
profile_distributionsrecebe os limites do scan anterior. - Perfis incrementais e
profile_depth=basicnão calculam quantis nem segmentos: não há o que comparar. - Cinco quantis não veem mudanças entre eles (uma distribuição bimodal com os mesmos quantis); o PSI vê-as quando existe.
- A comparação é com o scan anterior e não com uma baseline de vários scans: uma deriva lenta, abaixo dos limiares em cada passo, não dispara.
- O erro dos
APPROX_PERCENTILE(t-digest do fornecedor) não é publicado por quantil; os 5 % são o valor assumido, o mesmo da resolução doAPPROX_QUANTILES(x, 20).
| constante | valor | referência |
|---|---|---|
app.domain.quality.distribution_drift.SHIFT_WARNING |
0.5 | backend/app/domain/quality/distribution_drift.py:35 |
app.domain.quality.distribution_drift.SHIFT_CRITICAL |
1.0 | backend/app/domain/quality/distribution_drift.py:36 |
app.domain.quality.distribution_drift.PSI_WARNING |
0.1 | backend/app/domain/quality/distribution_drift.py:38 |
app.domain.quality.distribution_drift.PSI_CRITICAL |
0.25 | backend/app/domain/quality/distribution_drift.py:39 |
app.domain.quality.distribution_drift.PSI_EMPTY_SHARE |
0.0001 | backend/app/domain/quality/distribution_drift.py:40 |
app.domain.quality.distribution_drift.SCALE_EPSILON |
1e-9 | backend/app/domain/quality/distribution_drift.py:41 |
app.domain.quality.distribution_drift.JS_WARNING |
0.1 | backend/app/domain/quality/distribution_drift.py:43 |
app.domain.quality.distribution_drift.JS_CRITICAL |
0.25 | backend/app/domain/quality/distribution_drift.py:44 |
app.domain.quality.distribution_drift.CHI2_MIN_EXPECTED |
5.0 | backend/app/domain/quality/distribution_drift.py:45 |
app.domain.quality.distribution_drift.CHI2_ALPHA |
0.01 | backend/app/domain/quality/distribution_drift.py:46 |
app.domain.quality.distribution_drift.TOP_SHARES |
3 | backend/app/domain/quality/distribution_drift.py:47 |
app.domain.quality.distribution_drift.APPROX_RANK_ERROR |
0.05 | backend/app/domain/quality/distribution_drift.py:49 |
app.domain.quality.distribution_drift.NTILE_RANK_ERROR |
0.05 | backend/app/domain/quality/distribution_drift.py:50 |
app.domain.quality.distribution_drift.SAMPLE_CONFIDENCE_ALPHA |
0.05 | backend/app/domain/quality/distribution_drift.py:51 |
app.domain.rules.regression.DISTRIBUTION_RANGE_MARGIN |
0.5 | backend/app/domain/rules/regression.py:56 |
Anomalias
O que mede. Desvios do scan atual face ao histórico do próprio dataset, sem
regras: volume de linhas (row_count), percentagem de nulos (null_pct) e
cardinalidade (distinct_count) por coluna (backend/app/domain/quality/anomalies.py;
séries carregadas em backend/app/domain/scans/service.py:1951-2034). As séries de
null_pct e distinct_count só usam os scans com a mesma base do atual (sampled).
Depois de uma view passar ao perfil limitado (cartão #347), ou de sair dele, as
colunas só voltam a ser avaliadas com 5 scans na nova base. O row_count é exato nas
duas bases e entra sempre.
Fórmula — z-score modificado (Iglewicz & Hoaglin, 1993;
anomalies.py:152-171):
mediana = mediana(histórico)
MAD = mediana(|x_i − mediana|)
z = 0.6745 · (x − mediana) / MAD se MAD > 0
z = 0.7979 · (x − mediana) / meanAD se MAD = 0 e meanAD > 0
sem desvio (nenhum finding) se MAD = meanAD = 0 e x = mediana
z = ±99 se MAD = meanAD = 0 e x ≠ mediana
0,6745 é o quantil 0,75 da normal padrão: torna o MAD comparável ao desvio
padrão numa distribuição normal; 0,7979 (= √(2/π)) faz o mesmo para o desvio
absoluto médio. O z é limitado a ±99 para ficar serializável. A faixa normal
que a interface desenha inverte a fórmula com o limiar que decidiu o issue:
mediana ± T·MAD/0,6745 (backend/app/domain/issues/explanation.py:118-143). Os
issues anteriores ao cartão #319 não guardam o limiar e desenham-se com o 3,5 com
que foram abertos.
Decisão (anomalies.py:218-239, anomalies.py:247-292). Uma série é
anomalia quando cumpre as quatro condições:
- a baseline escolhida tem ≥ 5 pontos. Conta a baseline, não o histórico: uma baseline semanal mais curta já não é usada (ver abaixo);
- passa as guardas de relevância (
anomalies.py:295-299):row_countedistinct_countsó contam se|x − mediana| / max(|mediana|, 1) ≥ 10 %;null_pctsó se o desvio for ≥ 1 ponto percentual; |z| ≥ T(n), o limiar calibrado para o tamanho n da baseline (tabela abaixo;anomalies.py:352-354). Nunca fica abaixo do 3,5 clássico;- num scan, sobrevive ao controlo de multiplicidade do dataset:
Benjamini–Hochberg a α = 0,05 sobre os p-valores de todas as séries avaliadas
desse dataset (
anomalies.py:379-390;settings.anomaly_fdr_alpha, onde 0 desliga). O BH é um filtro a mais e nunca baixa o limiar por série. O monitor automático de volume avalia uma série só, por isso não o aplica.
Severidade: critical quando |z| ≥ 2·T(n) (com n grande, 7, como antes);
warning abaixo disso. Cada issue guarda no impact_json o z_threshold
aplicado, o p_value e a confidence (service.py:1324-1340).
Calibração (cartão #319). O 3,5 de Iglewicz & Hoaglin pressupõe um n grande. Com as baselines de 5 a 12 pontos usadas aqui, o MAD é muito ruidoso e o |z| tem caudas pesadas. Sem anomalia nenhuma e com ruído gaussiano, 12 % das séries passavam de 3,5 com n = 5, 4,8 % com n = 8 e 2,4 % com n = 12.
O harness backend/scripts/calibrate_anomalies.py mede isto. É só leitura, não
usa BD e corre com a seed 319: a mesma seed dá os mesmos números. Gera séries
sintéticas e passa-as pelo detetor real. O resultado está versionado em
docs/verificacao/anomalias-calibracao.json, e o código copia daí as tabelas
(tests/test_anomaly_calibration.py prende uma ao outro).
- Modelo nulo. Para cada n, 200 000 simulações sem anomalia. |z| ≈ s·|t_ν|, uma t de Student escalada, ajustada por mínimos quadrados em log aos quantis de 5 % a 0,1 %.
- Limiar. T(n) é o quantil do modelo a
SERIES_ALPHA= 0,5 % de falsos alarmes por série, arredondado para cima a 0,1. Entre os n calibrados vale o menor n ≤ ao da série, que é o lado conservador. - p-valor (
anomalies.py:357-367). É P(|t_ν| ≥ |z|/s), o mesmo modelo. Serve para ordenar e para o BH. Não é uma probabilidade exata fora do ruído gaussiano. - Confiança (
anomalies.py:370-376). Sai do p-valor, não do z cru, por isso tem em conta o n:highcom p ≤ 0,0001,mediumcom p ≤ 0,001 elowacima disso. A explicação da anomalia mostra-a, com o p-valor, o limiar aplicado e o n da baseline.
| n da baseline | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 20 | 25 | 30 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| limiar T(n) | 20,2 | 10,3 | 10,0 | 7,3 | 7,2 | 6,0 | 5,8 | 5,2 | 4,8 | 4,1 | 3,8 | 3,6 |
| falsos alarmes com 3,5 fixo | 12,0 % | 7,5 % | 6,9 % | 4,8 % | 4,5 % | 3,3 % | 3,0 % | 2,4 % | 1,8 % | 1,0 % | 0,8 % | 0,5 % |
O que o harness mede (20 000 séries por célula; ruído de 5 % do nível; os choques estão em σ robusto = MAD/0,6745 da população, por isso um choque de kσ dá z ≈ k). «Antes» é o detetor anterior ao #319: 3,5 fixo e baseline semanal a partir de 3 pontos. Cada célula dá a taxa só pelo z e a final, já com as guardas.
| série sem anomalia | n | antes: só z / final | agora: só z / final |
|---|---|---|---|
| ruído gaussiano | 5 · 8 · 12 | 12,3 / 3,6 · 5,0 / 2,1 · 2,4 / 1,4 % | 0,58 / 0,25 · 0,50 / 0,27 · 0,50 / 0,33 % |
| caudas pesadas (t₃) | 5 · 8 · 12 | 13,8 / 6,7 · 7,7 / 5,5 · 5,5 / 4,7 % | 0,8 / 0,5 · 1,6 / 1,4 · 2,4 / 2,2 % |
| tendência (+1 %/scan) | 5 · 8 · 12 | 18,4 / 0,1 · 8,5 / 0,3 · 2,2 / 0,6 % | 0,8 / 0,0 · 0,7 / 0,0 · 0,3 / 0,1 % |
| quase constante (1000 ± 1) | 5 · 8 · 12 | 14,9 / 0 · 10,2 / 0 · 11,1 / 0 % | 6,6 / 0 · 3,4 / 0 · 5,4 / 0 % |
| semanal, domingo a 30 % | 3 · 4 domingos | 27,5 / 0,5 · 14,1 / 0,3 % | 100 / 0,6 · 100 / 0,4 % |
| semanal, domingo a 30 % | 5 · 8 domingos | 11,7 / 0,3 · 4,7 / 0,1 % | 0,45 / 0,02 · 0,43 / 0,03 % |
| choque (final) | n = 5 antes / agora | n = 8 antes / agora | n = 12 antes / agora |
|---|---|---|---|
| 5σ | 85 / 9 % | 87 / 33 % | 87 / 55 % |
| 8σ | 99 / 20 % | 99,5 / 72 % | 99,8 / 94 % |
| 10σ | 99,8 / 28 % | 100 / 88 % | 100 / 99,3 % |
| 15σ | 100 / 49 % | 100 / 99,4 % | 100 / 100 % |
| queda de 20 % | 74 / 7 % | 73 / 19 % | 72 / 34 % |
| queda de 50 % | 99,9 / 28 % | 100 / 89 % | 100 / 99,5 % |
| queda de 90 % | 100 / 61 % | 100 / 99,97 % | 100 / 100 % |
Teto do recall. Para saber o que é atingível com tão poucos pontos, o harness corre também, sobre as mesmas séries e com as mesmas guardas, o teste ótimo com ruído gaussiano: a t de previsão |x − média| / (s·√(1 + 1/n)), comparada com a t de Student de n − 1 graus de liberdade. Com ruído gaussiano dá exatamente α de falsos alarmes, e nenhum teste que não dependa do nível e da escala da série tem mais recall ao mesmo α. Não é robusto: com caudas t₃ e n = 8 dá 2,0 % de falsos alarmes a α = 0,5 %, contra 1,4 % do calibrado. Por isso serve de referência e não vai para produção.
| choque (final) | n = 8: calibrado · teto a 0,5 % · teto a 1 % | n = 12: calibrado · teto a 0,5 % · teto a 1 % |
|---|---|---|
| 5σ | 33 · 72 · 84 % | 55 · 86 · 93 % |
| 8σ | 72 · 99,2 · 99,8 % | 94 · 99,98 · 100 % |
| queda de 20 % | 19 · 47 · 63 % | 34 · 65 · 78 % |
| dataset de C séries ruidosas (20 %) | antes | agora, sem BH | BH com p da normal | agora (BH com p calibrado) |
|---|---|---|---|---|
| C = 20, n = 8 · 12: algum falso alarme no scan | 63 · 38 % | 9,2 · 8,5 % | 9,2 · 8,5 % | 4,9 · 4,3 % |
| C = 100, n = 8 · 12: algum falso alarme no scan | 99 · 91 % | 40 · 38 % | 40 · 38 % | 4,4 · 5,6 % |
| C = 100, n = 12: recall de um choque de 10σ · 20σ | 100 · 100 % | 99,4 · 100 % | 99,4 · 100 % | 80 · 100 % |
A leitura destes números:
- O que o cartão pedia e o que se mede. Falsos alarmes por série estável ≤ 1 % no limiar por omissão: 0,50–0,58 % só pelo z e 0,25–0,33 % com as guardas. O recall ≥ 90 % para choques de 5σ com n ≥ 8 não é atingível com 8 pontos e ≤ 1 % de falsos alarmes: nem o teto gaussiano passa dos 84 %. Com 12 pontos, só o teto a 1 % lá chega (93 %). O 3,5 antigo também não passava dos 87 %, e pagava-o com 2–5 % de falsos alarmes por série. Com a calibração, o recall chega a ≥ 90 % a partir de 10σ com n = 12 e de 15σ com n = 8. Uma queda de 50 % numa série com 5 % de ruído é apanhada em 89 % dos casos com n = 8 e em 99,5 % com n = 12.
- O BH só funciona com o p-valor calibrado. Com o p-valor da normal, o BH não muda nada: um z de 3,5 «vale» p = 0,0005, 50 a 250 vezes menos do que o real com n entre 5 e 12. Com o p-valor calibrado, a probabilidade de algum falso alarme num dataset de 100 séries ruidosas desce de 38–40 % para ~5 % por scan. O custo é recall em choques moderados nos datasets largos.
- O preço de baselines curtas. Com n = 5–7, só desvios grosseiros alertam (T de 10 a 20). Numa série quase constante o MAD é minúsculo e o z de um incidente real passa muito o limiar; numa série com 5 % de ruído, uma queda de 90 % com 5 pontos fica por apanhar em 39 % dos casos.
Histórico e baseline. São carregados até 60 scans concluídos
(service.py:195); com menos de 5 pontos não há avaliação. A baseline é
(anomalies.py:185-202):
- semanal — se, entre os pontos carregados, houver ≥ 5 no mesmo dia da semana (em UTC) do scan atual, usam-se os ≤ 8 mais recentes desse dia (≈ 2 meses);
- global — caso contrário, os 12 pontos mais recentes. Com 3 ou 4 pontos do mesmo dia da semana, a baseline é global, mas o valor também tem de passar a guarda de relevância face à mediana desses 3–4 pontos: o «domingo baixo» não volta a ser falso alarme. No harness, 0,4–0,6 % de falsos alarmes; sem esta guarda, todos os domingos.
A escolha fica registada no issue (impact_json.baseline, baseline_weekday) e é
mostrada na explicação, com o n da baseline. Antes do #319 a baseline semanal
entrava com 3 pontos. Um MAD sobre 3 pontos é degenerado: um dos desvios é 0, o
que dava 27 % de séries acima de 3,5 sem anomalia nenhuma.
Feedback (cartão #157). O identificador é o subtype (metric[:coluna],
truncado a 50 caracteres com hash). Marcar um issue de anomalia como «ignorado»
sobe um degrau o limiar dessa métrica (e coluna) nos scans seguintes; resolvê-lo
desce um degrau. Os degraus (anomaly_tuning_steps, omissão 3,5 → 5,0 → 7,0) estão
na escala clássica do |z|: o limiar aplicado é o calibrado × degrau / 3,5
(anomalies.py:effective_threshold), por isso o 5,0 multiplica-o por 1,43 e o 7,0
por 2. No último degrau, mais um «ignorar» silencia a métrica (4 no total), e só
essas saem do scan (anomaly_tuning.py:silenced_subtypes). O estado vive em
datasets.anomaly_tuning_json e o POST /datasets/{id}/tuning/reset repõe os
limiares calibrados.
Limitações.
- O MAD é robusto mas pouco eficiente: com ruído gaussiano, a eficiência assintótica face ao desvio padrão é de ~37 % (Rousseeuw & Croux, 1993), ou seja, n pontos estimam a escala como ~0,37·n com o desvio padrão. Daí a distância entre o calibrado e o teto ao mesmo α (5σ com n = 8: 33 % contra 72 %). Um estimador de escala robusto e mais eficiente (o Qn dos mesmos autores, 82 %) aproximaria o recall do teto, mas obriga a recalibrar as tabelas.
- A calibração assume ruído gaussiano. Séries de caudas pesadas têm outliers genuínos e dão mais falsos alarmes (t₃: 0,5–2,2 % por série, já com as guardas). Séries discretas ou quase constantes também não seguem o modelo, porque o MAD é 0 com frequência; aí quem segura é a guarda de relevância (0 % no harness).
- O BH controla a taxa de falsas descobertas de cada dataset, não a probabilidade
de haver pelo menos um falso alarme. Quando há uma descoberta verdadeira, os
limiares das outras séries alargam: até 10,5 % de datasets com algum falso alarme
no harness. As séries de um dataset
não são independentes (
row_counte distintos movem-se juntos). Com dependência positiva o BH mantém-se válido (Benjamini & Yekutieli, 2001), mas o modelo continua a ser uma aproximação. - Com 3–4 pontos do mesmo dia da semana, um domingo «alto» (ao nível dos dias úteis) não é anomalia, porque fica dentro da baseline global.
- Um histórico que já contém a anomalia «aprende-a» a partir do momento em que ela passa a mediana; o MAD torna o método robusto a poucos outliers, não a mudanças de regime.
- Os instantes usam o
created_atdo scan, não a hora dos dados.
Drill-down por segmento (backend/app/domain/quality/segments.py). Quando uma
anomalia de row_count ou null_pct dispara e o perfil é standard ou deep, o
scan já guardou um snapshot GROUP BY das colunas categóricas de baixa cardinalidade
(até 3 dimensões com 2 a 50 valores distintos, < 95 % de nulos, tipo não
contínuo/temporal; segments.py:70-96, guardas em settings.anomaly_segment_*). O
esperado por segmento é a mediana por segmento dos últimos 12 snapshots
(service.py:1837-1858); para nulos é a taxa do próprio segmento ou, sem histórico
dessa coluna, a taxa global esperada (segments.py:225-256). A atribuição
(segments.py:174-205) considera só os segmentos que se desviam na direção da
anomalia e calcula share = |Δ_segmento| / Σ|Δ| — os shares somam 1; um
segmento é «dominante» com share ≥ 0,5; mostram-se os 3 maiores; a dimensão
escolhida é a de maior share de topo. É aritmética exata sobre contagens da
fonte (method="pushdown_group_by"); fontes sem pushdown (ficheiros) não têm
drill-down, porque uma amostra das primeiras linhas não é representativa por
segmento.
| constante | valor | referência |
|---|---|---|
app.domain.quality.anomalies.MIN_HISTORY_POINTS |
5 | backend/app/domain/quality/anomalies.py:35 |
app.domain.quality.anomalies.Z_THRESHOLD |
3.5 | backend/app/domain/quality/anomalies.py:36 |
app.domain.quality.anomalies.Z_CRITICAL |
7.0 | backend/app/domain/quality/anomalies.py:37 |
app.domain.quality.anomalies._MAD_SCALE |
0.6745 | backend/app/domain/quality/anomalies.py:38 |
app.domain.quality.anomalies._MEANAD_SCALE |
0.7979 | backend/app/domain/quality/anomalies.py:39 |
app.domain.quality.anomalies._Z_CAP |
99.0 | backend/app/domain/quality/anomalies.py:40 |
app.domain.quality.anomalies._MIN_RELATIVE_DELTA |
0.10 | backend/app/domain/quality/anomalies.py:44 |
app.domain.quality.anomalies._MIN_NULL_PCT_DELTA |
1.0 | backend/app/domain/quality/anomalies.py:45 |
app.domain.quality.anomalies.GLOBAL_HISTORY_POINTS |
12 | backend/app/domain/quality/anomalies.py:48 |
app.domain.quality.anomalies.MIN_WEEKDAY_POINTS |
5 | backend/app/domain/quality/anomalies.py:51 |
app.domain.quality.anomalies.WEEKDAY_HINT_POINTS |
3 | backend/app/domain/quality/anomalies.py:54 |
app.domain.quality.anomalies.MAX_WEEKDAY_POINTS |
8 | backend/app/domain/quality/anomalies.py:55 |
app.domain.quality.anomalies.SERIES_ALPHA |
0.005 | backend/app/domain/quality/anomalies.py:71 |
app.domain.quality.anomalies.THRESHOLD_BY_N[5] |
20.2 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[6] |
10.3 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[7] |
10.0 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[8] |
7.3 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[9] |
7.2 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[10] |
6.0 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[11] |
5.8 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[12] |
5.2 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[15] |
4.8 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[20] |
4.1 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[25] |
3.8 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.THRESHOLD_BY_N[30] |
3.6 | backend/app/domain/quality/anomalies.py:73-76 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[5] |
1.389, 1.97 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[6] |
1.303, 2.87 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[7] |
1.227, 2.8 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[8] |
1.205, 3.68 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[9] |
1.188, 3.68 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[10] |
1.153, 4.5 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[11] |
1.111, 4.39 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[12] |
1.13, 5.36 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[15] |
1.072, 5.77 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[20] |
1.064, 8.4 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[25] |
1.056, 10.25 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.NULL_MODEL_BY_N[30] |
1.04, 12.52 | backend/app/domain/quality/anomalies.py:78-82 |
app.domain.quality.anomalies.CONFIDENCE_HIGH_P |
0.0001 | backend/app/domain/quality/anomalies.py:84 |
app.domain.quality.anomalies.CONFIDENCE_MEDIUM_P |
0.001 | backend/app/domain/quality/anomalies.py:85 |
app.domain.scans.service.ANOMALY_HISTORY_SCANS |
12 | backend/app/domain/scans/service.py:246 |
app.domain.scans.service.ANOMALY_SEASONAL_HISTORY_SCANS |
60 | backend/app/domain/scans/service.py:249 |
app.domain.quality.segments.MIN_DOMINANT_SHARE |
0.5 | backend/app/domain/quality/segments.py:33 |
app.domain.quality.segments.TOP_SEGMENTS |
3 | backend/app/domain/quality/segments.py:34 |
app.domain.quality.segments.MAX_NULL_COLUMNS |
10 | backend/app/domain/quality/segments.py:35 |
app.config.settings.Settings.model_fields["anomaly_segment_max_dimensions"].default |
3 | backend/app/config/settings.py:968 |
app.config.settings.Settings.model_fields["anomaly_segment_max_cardinality"].default |
50 | backend/app/config/settings.py:971 |
app.config.settings.Settings.model_fields["anomaly_segment_max_rows"].default |
50000000 | backend/app/config/settings.py:974 |
app.config.settings.Settings.model_fields["anomaly_fdr_alpha"].default |
0.05 | backend/app/config/settings.py:982 |
Monitores automáticos
O que mede. Cobertura por omissão de atualidade, volume e schema em todos os
datasets da conexão, até ao teto por scan (abaixo), a partir dos snapshots de
metadados do catálogo (sem regras;
backend/app/domain/quality/auto_monitors.py). Severidade nunca critical.
- Volume (
backend/app/domain/quality/auto_monitors.py:183-265): com menos de 5 pontos de histórico está em «modo arranque» e alerta só numa queda ≥ 50 % face ao snapshot anterior; a partir de 5 pontos usa o mesmo z-score modificado das anomalias, com o limiar calibrado T(n) para o n de pontos do histórico (com 30 snapshots, 3,6; com 8, 7,3) e sem BH, porque é uma série só. Corre sobre todo o histórico carregado — até 30 snapshots (backend/app/domain/scans/auto_monitors.py:59), sem a janela de 12 pontos nem a baseline semanal, porque a série é passada sem instantes (backend/app/domain/quality/auto_monitors.py:243,anomalies.py:187-188). - Atualidade (
backend/app/domain/quality/auto_monitors.py:291-360): os instantes de atualização vêm dolast_modifiedda fonte ou, sem ele, das variações do contador de escritas entre snapshots (resolução = cadência de scan).cadência = mediana dos intervalose o alerta dispara quando a idade excedemax(tolerância · cadência, 1.25 · maior intervalo, 1 h), com tolerância 2,0 (≥ 3 intervalos) ou 3,0 (menos). Com menos de duas atualizações observadas fica «a aprender». - Schema: compara colunas e tipos com o snapshot anterior (mesma lógica do drift).
Onde há metadados. O snapshot vem de BaseConnector.table_metadata
(backend/app/infra/connectors/base.py:876-883). Nas bases de dados e warehouses
abaixo é só catálogo: nunca um COUNT(*) nem uma leitura das tabelas
(backend/app/infra/connectors/sql.py:1348-1354). Fora delas a regra muda (Kafka,
ficheiros e Pub/Sub, no fim da lista). O que cada fonte expõe decide que monitores
se podem ativar.
Quando o scan também perfilou o dataset, só a contagem exata do perfil ganha à
do catálogo (backend/app/domain/scans/auto_monitors.py:240-246). Na data de
atualização é ao contrário: a data de modificação do catálogo ganha, e o
timestamp mais recente do perfil só entra quando o catálogo não traz data
(backend/app/domain/scans/auto_monitors.py:248-250) — em PostgreSQL e SQL
Server, ou quando a fonte a devolve vazia. Por isso, em BigQuery, Snowflake,
MySQL e Databricks a atualidade segue a data de modificação que o catálogo regista
para a tabela (que, conforme o motor, também pode mudar com DDL ou manutenção), não
o valor mais recente das colunas temporais dos dados.
- BigQuery:
row_count(soma) elast_modified(máximo) das partições emINFORMATION_SCHEMA.PARTITIONS; se a vista não for legível, de__TABLES__(backend/app/infra/connectors/bigquery.py:757-796). - Snowflake:
ROW_COUNTeLAST_ALTEREDdeINFORMATION_SCHEMA.TABLES(backend/app/infra/connectors/snowflake.py:95-112). - Databricks: só o
lastModifieddeDESCRIBE DETAIL(backend/app/infra/connectors/databricks.py:233-242). Sem contagem no catálogo, o volume só existe nos datasets que o scan perfila. - PostgreSQL: contagem estimada (
n_live_tup, oureltuplesnuma tabela nunca analisada) e contador de escritas (n_tup_ins + n_tup_upd + n_tup_del) depg_stat_user_tables/pg_class(backend/app/infra/connectors/sql.py:1362-1380). Não há data de modificação: a atualidade vem das variações desse contador; num dataset perfilado com uma coluna temporal, o timestamp do perfil passa à frente do contador (backend/app/domain/quality/auto_monitors.py:271-288). - MySQL:
TABLE_ROWS(estimativa no InnoDB) eUPDATE_TIMEdeinformation_schema.TABLES(backend/app/infra/connectors/sql.py:1381-1391). - SQL Server: contagem de
sys.dm_db_partition_stats, ou desys.partitionscom um login sódb_datareader(backend/app/infra/connectors/mssql.py:349-370). Omodify_datesó muda com DDL e nunca serve de atualidade: esta só existe nos datasets perfilados com uma coluna temporal. - Microsoft Fabric e SQLite: sem estatísticas de catálogo; o snapshot tem só as colunas (schema).
- Kafka: a contagem são as mensagens retidas e o contador de escritas é a soma
dos high watermarks. Para a data, lê a última mensagem de cada partição e usa só
o seu timestamp (
backend/app/infra/connectors/kafka.py:792-818). Não traz colunas: o schema só entra nos datasets perfilados. - CSV: colunas inferidas das primeiras linhas do ficheiro (até 10 000) e a data
de modificação do ficheiro no sistema de ficheiros
(
backend/app/infra/connectors/csv_file.py:533-541). Sem contagem, que obrigaria a ler o ficheiro inteiro: o volume só existe nos datasets perfilados. - Pub/Sub: nada. Só confirma que o tópico existe, sem pull, para não consumir a
subscrição de amostragem (
backend/app/infra/connectors/pubsub.py:891-895). Volume, atualidade e schema só nos datasets perfilados. - Restantes conectores (armazenamento de objetos, aplicações SaaS,
documentos): só as colunas de
get_columns(schema). Nos ficheiros de armazenamento de objetos, o tipo das colunas é inferido das primeiras linhas.
Teto por scan. Por conexão e por scan, no máximo 500 datasets e 120 s, pela
ordem schema/tabela (backend/app/domain/scans/auto_monitors.py:123-147). O que
fica de fora conta em scans.stats_json.auto_monitors.skipped e não ganha
snapshot nesse scan. Como a ordem é fixa, os que ficam de fora são sempre os do
fim da lista.
Todos os valores são exatos face ao que a fonte expõe; o que a fonte não expõe (sem contagem no catálogo, sem data de modificação) fica marcado «indisponível», nunca inventado.
| constante | valor | referência |
|---|---|---|
app.domain.quality.auto_monitors.BOOTSTRAP_DROP_PCT |
50.0 | backend/app/domain/quality/auto_monitors.py:40 |
app.domain.quality.auto_monitors.FRESHNESS_TOLERANCE |
2.0 | backend/app/domain/quality/auto_monitors.py:43 |
app.domain.quality.auto_monitors.FRESHNESS_TOLERANCE_FEW_POINTS |
3.0 | backend/app/domain/quality/auto_monitors.py:44 |
app.domain.quality.auto_monitors.FRESHNESS_FEW_INTERVALS |
3 | backend/app/domain/quality/auto_monitors.py:45 |
app.domain.quality.auto_monitors.FRESHNESS_MAX_INTERVAL_FACTOR |
1.25 | backend/app/domain/quality/auto_monitors.py:46 |
app.domain.quality.auto_monitors.FRESHNESS_MIN_WINDOW.seconds |
3600 | backend/app/domain/quality/auto_monitors.py:47 |
app.domain.scans.auto_monitors.HISTORY_SNAPSHOTS |
30 | backend/app/domain/scans/auto_monitors.py:61 |
app.infra.connectors.csv_file._INFERENCE_ROW_LIMIT |
10000 | backend/app/infra/connectors/csv_file.py:77 |
app.config.settings.Settings.model_fields["auto_monitor_max_datasets"].default |
500 | backend/app/config/settings.py:1013 |
app.config.settings.Settings.model_fields["auto_monitor_budget_s"].default |
120 | backend/app/config/settings.py:1016 |
Coluna de frescura e fuso da fonte
O que mede. A idade dos dados de uma tabela, profiled_at − latest_timestamp,
que a regra freshness, o SLO, o trust score e o monitor de atualidade (quando o
catálogo não dá last_modified) usam. O latest_timestamp é o máximo de uma
coluna de data/hora, lido do perfil já calculado, sem query nova
(backend/app/domain/quality/freshness.py).
- Que coluna. A fixada no dataset (
PATCH /datasets/{id}/freshness), se ainda estiver no perfil. Senão, a escolha automática entre as colunas DATE/TIMESTAMP com máximo legível, sem nome de prazo, validade, fim, nascimento ou apagamento (due,vencimento,expir…,valid_to,end,fim,birth,nascimento,deleted…; as palavras curtas contam só como palavra inteira, paravendornão passar porend) e sem máximo mais de 24 h depois do perfil. Ordem: nomes de alteração (updated,modified,changed,atualiz…) > carga (ingest,loaded,etl) > criação (created,criad…) > evento (event,occurred) > outros TIMESTAMP > DATE; no mesmo nível, TIMESTAMP antes de DATE e depois a ordem do esquema. Numa tabela com colunas de data mas sem candidata, a frescura fica por medir. Antes media-se a 1.ª coluna de data: uma data de nascimento dava «desatualizada» para sempre e um vencimento «fresca» para sempre. Os tópicos Kafka e Pub/Sub usam sempre o timestamp do broker. - Datas no futuro. Uma coluna com máximo depois de perfil + 24 h fica fora da
escolha automática e gera o alerta de perfil
future_timestamps(info). Uma regrafreshnesscomcolumnnessa coluna falha com erro em vez de mostrar «idade 0». - Regra. Com
column, a regra mede essa coluna (omax_valuedela no perfil); semcolumn, a coluna escolhida acima. A regra que o 1.º scan cria leva a coluna escolhida explícita, e fixar a coluna no dataset muda também a regra. - Fuso. Valores sem fuso (
timestamp without time zone, CSV) são hora local dosource_timezoneda conexão (omissão UTC) e convertem-se para UTC; na hora repetida da mudança de hora vale a 1.ª ocorrência. Valores com fuso não mudam. O perfil guardalatest_timestamp_tz(awareouassumed:<fuso>) e a UI mostra «assumido UTC». - Fora de âmbito. A baseline semanal das anomalias continua em UTC.
| constante | valor | referência |
|---|---|---|
app.domain.quality.freshness.FUTURE_TOLERANCE_HOURS |
24 | backend/app/domain/quality/freshness.py:33 |
app.domain.quality.freshness.DEFAULT_TIMEZONE |
UTC | backend/app/domain/quality/freshness.py:35 |
Anomalias de custo
O que mede. Picos do custo diário de compute por dataset (quando a plataforma
atribui custo por tabela) ou por conta da conexão
(backend/app/domain/finops/cost_anomalies.py:128-178). Reutiliza o z-score
modificado das anomalias sobre a série diária, com zeros nos dias
sem custo (um dia sem jobs custou mesmo zero).
Onde há custo por tabela. Hoje só no BigQuery: os bytes faturados de
INFORMATION_SCHEMA.JOBS_BY_PROJECT repartidos pelas tabelas de cada job
(backend/app/infra/connectors/bigquery.py:802-859), com as queries mais caras de
cada tabela e dia e os literais mascarados
(backend/app/infra/connectors/bigquery.py:913-963). Na Snowflake
(WAREHOUSE_METERING_HISTORY, backend/app/infra/connectors/snowflake.py:118-162)
e no Databricks (system.billing.usage,
backend/app/infra/connectors/databricks.py:248-291) o custo é da conta: o pico é
por conexão e sai sem queries.
Decisão. Só desvios para cima. Avalia hoje e ontem (o dia corrente está a
meio); histórico mínimo de 7 dias, para a sazonalidade semanal não passar por pico.
Guardas: o excesso sobre a mediana tem de ser ≥ 1 USD/dia e ≥ 50 % da mediana;
depois z ≥ 3,0 → warning, z ≥ 7 → critical. Um pico por série (o de maior
z), com as 5 queries mais caras do dia quando existem. Os valores são convertidos
a EUR com settings.usd_to_eur. Issue «ignorado» silencia a série.
| constante | valor | referência |
|---|---|---|
app.domain.finops.cost_anomalies.COST_Z_THRESHOLD |
3.0 | backend/app/domain/finops/cost_anomalies.py:52 |
app.domain.finops.cost_anomalies.MIN_HISTORY_DAYS |
7 | backend/app/domain/finops/cost_anomalies.py:54 |
app.domain.finops.cost_anomalies.EVAL_DAYS |
2 | backend/app/domain/finops/cost_anomalies.py:57 |
app.domain.finops.cost_anomalies._MIN_RELATIVE_DELTA |
0.5 | backend/app/domain/finops/cost_anomalies.py:58 |
app.domain.finops.cost_anomalies._MIN_ABS_DELTA_USD |
1.0 | backend/app/domain/finops/cost_anomalies.py:59 |
app.domain.finops.cost_anomalies._TOP_CULPRITS |
5 | backend/app/domain/finops/cost_anomalies.py:60 |
app.config.settings.Settings.model_fields["usd_to_eur"].default |
0.92 | backend/app/config/settings.py:1306 |
Duplicados
O que mede. Grupos (clusters) de linhas iguais ou quase iguais dentro de um
dataset, sobre as ≤ 4 colunas de texto de maior cardinalidade
(backend/app/domain/quality/columns.py:29-33) e sobre a amostra
de ≤ 5 000 linhas (backend/app/domain/quality/duplicates.py).
Normalização (duplicates.py:197-210): None → ""; strings → strip() +
lower(); outros valores → str() seguido de strip() + lower() (True →
"true"). Não remove acentos nem pontuação.
Exatos (duplicates.py:63-91): a tupla normalizada das colunas, unida por
\x1f, é hashada com MD5; linhas com o mesmo hash formam um cluster (≥ 2 linhas).
confidence = 1.0. Determinístico e exato dentro da amostra.
Fuzzy (duplicates.py:94-191):
- as colunas normalizadas são concatenadas por espaço;
- blocking em várias passagens: cada coluna preenchida dá uma chave de
bloco (
block_keys,duplicates.py:242-255) e duas linhas comparam-se se partilharem uma chave em qualquer coluna e se os comprimentos da concatenação diferirem no máximomax(2, 20 % do comprimento maior)(duplicates.py:22-23, 300-318). A chave de um valor (column_block_key,duplicates.py:220-239): num email, só a parte local; num valor sobretudo com dígitos (telefone, NIF, código postal), os últimos 4 dígitos, que sobrevivem ao «+351» e aos espaços; no resto, os 3 primeiros caracteres do token mais longo, sem acentos (em empate de comprimento, o menor por ordem alfabética,longest_token). O resultado não depende da ordem das colunas, que o scan escolhe pela cardinalidade. Até ao cartão #352 a chave era o 1.º carácter da concatenação, ou seja, da coluna com mais valores distintos (ver o benchmark); - scorer =
max(token_sort_ratio, ratio)do rapidfuzz — oratioé a semelhança Indel normalizada (0–100), otoken_sort_ratioaplica-a às palavras ordenadas (apanha trocas de ordem); o par liga-se quando a pontuação é ≥threshold · 100, comthreshold = 0.9desde o #352, antes 0,85 (FUZZY_THRESHOLD,duplicates.py:31, 356-359); pares exatamente iguais são saltados (já são exatos); - os pares ligados formam clusters por union-find (transitividade: A
B e BC juntam A, B e C mesmo que A e C não sejam parecidos); confidence= média das pontuações dos pares que ligaram o cluster ÷ 100, arredondada a 4 casas e limitada a 0,9999 (duplicates.py:175): o 1,0 fica para os exatos, e uma troca de tokens dátoken_sort_ratio100 sem ser exata. Não é uma probabilidade: é a semelhança média dos pares acima do limiar, logo está entre 0,9 e 0,9999 (entre 0,85 e 1,0 nos clusters gravados antes do #352);- banda (
cluster_band,duplicates.py:258-284): a API devolveconfidence_band(highoulow;exactsó comconfidence = 1e linhas iguais na amostra, o que trata os clusters fuzzy antigos gravados com 1,0) ereference_precision, a fração de clusters do benchmark com essa semelhança cujas linhas eram todas da mesma entidade (CONFIDENCE_BANDS). A interface mostra a banda («Alta», «Baixa», «Exata») em vez da percentagem, com essa precisão no tooltip e a ressalva de que vem de um benchmark sintético português, não dos dados do cliente (frontend/src/lib/duplicateBand.ts).
Acima de 8 M de pares candidatos num bloco o cálculo passa a modo matricial
(duplicates.py:297); o resultado é o mesmo. O max_rows=5000 da função coincide
com o limite da amostra.
Issue. «% da tabela» quando a amostra é a tabela inteira; «% da amostra»
quando não é (covers_table); warning abaixo de 5 % e critical a partir daí
(backend/app/domain/scans/service.py:1339-1380). A base — amostra ou tabela —
é a mesma da dimensão «unicidade» do score (service.py:1643-1654). A interface mostra a
banda de confiança e até 5 linhas de exemplo por cluster.
Intervalo (cartão #350). Com amostra, a descrição diz também o intervalo de
confiança a 95 % da taxa na tabela — «90,0% da amostra; na tabela, entre 63,9% e
97,9% (IC 95%)» — e o impact_json traz estimated, duplicate_pct e
duplicate_pct_ci95 (pontos percentuais). É o intervalo de Wilson
(wilson_interval em backend/app/domain/quality/stats.py) com a correção de
população finita pelo row_count da tabela: o n efetivo é n · (N − 1) / (N − n),
por isso uma amostra que lê metade da tabela tem um intervalo mais estreito e a
tabela inteira não tem intervalo. A dimensão «unicidade» do score leva o mesmo
intervalo, virado para a escala 0–100 (dimensions_meta.unicidade.ci95;
compute_score em scoring.py). O intervalo mede só a variação da amostra: não
corrige os pares cujo duplicado ficou fora dela (a taxa da amostra tende a ficar
abaixo da da tabela). As primeiras linhas (head, ou um ficheiro lido só até um
teto) não são uma amostra aleatória: sobre elas a taxa é exata e o intervalo só vale
para a tabela se essas linhas a representarem. A descrição di-lo assim — «20,0% da
amostra; entre 6,6% e 47,1% (IC 95%) só se as primeiras linhas representarem a
tabela» — e não fala da tabela sem a condição.
Limitações. Só vê pares dentro da amostra — a do método do dialeto, dita no issue; duas linhas que não partilham a chave de bloco de nenhuma coluna nunca se comparam; a pontuação é sobre a concatenação (colunas longas dominam) e um campo vazio de um dos lados pesa como uma diferença — encurta a concatenação, que pode sair da janela de comprimento, e baixa a pontuação. No benchmark só 30 % dos pares com um campo em falta ficam juntos; não há pesos por coluna nem normalização por tipo (para isso existe a correspondência entre datasets).
Benchmark rotulado (cartão #352)
backend/scripts/bench_matching.py gera, com seed fixa, entidades sintéticas
portuguesas (backend/tests/fixtures/matching_bench.py: nomes e concelhos dos
seed packs, NIF com dígito de controlo, moradas, emails e telefones), 20 % de
empresas, 15 % de negativos difíceis (homónimos, pessoas do mesmo agregado com
a mesma morada, empresas irmãs na mesma sede com telefone seguido) e, em 30 % das
entidades, 1–3 cópias com 1–2 perturbações rotuladas: acentos, gralhas (1–2
edições), transposições, trocas de tokens, abreviaturas («Lda»/«Limitada»,
«R.»/«Rua»), pontuação e campos em falta (qualquer coluna, o nome incluído). A
tabela tem as colunas nome, email, telefone, morada e o motor recebe-as na
ordem que o scan lhes dá (duplicate_text_columns, a maior cardinalidade
primeiro): nome, telefone, morada, email em 2 000 linhas e telefone, morada, nome, email em 10 000. Mede precision/recall/F1 por pares sobre os clusters
(a transitividade do union-find conta) por limiar (0,80–0,95), scorer e
blocking; a grelha é recalculada pelo script e a célula por omissão tem de dar o
mesmo que o motor real (backend/tests/test_matching_bench.py). Resultados
completos em docs/verificacao/matching-bench.json; --check diz se o JSON
está em dia.
| 10 000 linhas (4 263 pares verdadeiros) | recall do blocking | F1 a 0,85 | F1 a 0,90 |
|---|---|---|---|
| 1.º carácter (antes do #352) | 0,704 | 0,776 | 0,791 |
| 3 primeiros caracteres normalizados da 1.ª coluna | 0,711 | 0,787 | 0,795 |
| token mais longo da 1.ª coluna, sem acentos | 0,666 | 0,760 | 0,767 |
| uma chave por coluna (agora) | 0,844 | 0,849 | 0,871 |
| sem blocking (só a janela de comprimento) | 0,844 | 0,849 | 0,871 |
F1 com max(token_sort_ratio, ratio). Uma chave por coluna chega aos mesmos
pares que comparar tudo, com 16 % das comparações (6,8 M contra 41,8 M em 10 000
linhas). A 1.ª tentativa do #352, o token mais longo da 1.ª coluna, ficava abaixo
do 1.º carácter quando a 1.ª coluna era o telefone. Em 2 000 linhas o motor passa
de 0,749 (antes do #352) a 0,885 (precisão 0,969, recall 0,814); em 10 000, de
0,776 a 0,871 (precisão 0,923, recall 0,824). Na ordem do scan, com cada uma
das quatro colunas à frente, o F1 de 2 000 linhas varia 0,002.
O recall do blocking inclui a janela de comprimento, que sozinha já perde 16 %
dos pares. Os campos em falta mudam o comprimento da concatenação bem mais do que
20 % e são a perturbação com pior recall. Entre scorers, com uma chave por coluna
e o limiar 0,90 (10 000 linhas): token_set_ratio 0,892, WRatio 0,878,
max(token_sort_ratio, ratio) 0,871, token_sort_ratio 0,857, ratio 0,849 e
Jaro–Winkler sobre a concatenação 0,814. O token_set_ratio também ganha em 2 000
linhas (0,906 contra 0,885), mas dá 100 a qualquer par em que as palavras de uma
linha estão todas na outra: uma linha só com o nome liga-se a todas as que têm
esse nome. O benchmark quase não tem esse caso, por isso o scorer não muda sem o
feedback do #157. O limiar 0,90 fica no patamar do melhor F1: o melhor é 0,92 em
2 000 linhas (0,8850 contra 0,8847) e 0,91 em 10 000 (0,8740 contra 0,8705); a
0,85 o F1 é 0,874 e 0,849.
Recall por perturbação (10 000 linhas, defaults): acentos 0,89, pontuação 0,88, transposições 0,88, abreviaturas 0,87, gralhas 0,87, trocas de tokens 0,86, campos em falta 0,30.
Bandas. A fração de clusters «puros» (todas as linhas da mesma entidade) por
confidence, medida com o motor a 0,85, o limiar mais baixo que já gravou
clusters, para cobrir também os antigos:
| banda | confidence |
clusters puros (10 000 linhas) | clusters |
|---|---|---|---|
| alta | ≥ 0,91 | 95,4 % | 1 811 |
| média | — | nenhum intervalo abaixo da alta chega a 80 % | 0 |
| baixa | < 0,91 | 28,7 % | 122 |
O corte da alta é o menor confidence com ≥ 95 % de clusters puros e ≥ 30
clusters; a média seria o intervalo seguinte com ≥ 80 %, que este benchmark não
tem. A baixa mistura os clusters entre 0,85 e 0,90, que só existem nos scans
antigos, com os novos entre 0,90 e 0,91. São números de uma referência
sintética: dizem como o método se porta com perturbações conhecidas, não
quantos clusters de um cliente são duplicados reais — essa calibração vem do
feedback em produção (cartão #157).
| constante | valor | referência |
|---|---|---|
app.domain.quality.duplicates.FUZZY_THRESHOLD |
0.9 | backend/app/domain/quality/duplicates.py:31 |
app.domain.quality.duplicates.find_fuzzy_duplicates.__defaults__[1] |
5000 | backend/app/domain/quality/duplicates.py:94-99 |
app.domain.quality.duplicates.FUZZY_MAX_CONFIDENCE |
0.9999 | backend/app/domain/quality/duplicates.py:34 |
app.domain.quality.duplicates.BLOCK_PREFIX_CHARS |
3 | backend/app/domain/quality/duplicates.py:28 |
app.domain.quality.duplicates.BLOCK_SUFFIX_DIGITS |
4 | backend/app/domain/quality/duplicates.py:29 |
app.domain.quality.duplicates.CONFIDENCE_BANDS[0][0] |
0.91 | backend/app/domain/quality/duplicates.py:42-44 |
app.domain.quality.duplicates.CONFIDENCE_BANDS[0][2] |
0.954 | backend/app/domain/quality/duplicates.py:42-44 |
app.domain.quality.duplicates.CONFIDENCE_BANDS[1][2] |
0.287 | backend/app/domain/quality/duplicates.py:42-44 |
app.domain.quality.duplicates.MAX_SAMPLE_ROWS |
5 | backend/app/domain/quality/duplicates.py:18 |
app.domain.quality.duplicates._LENGTH_TOLERANCE_PCT |
0.2 | backend/app/domain/quality/duplicates.py:22 |
app.domain.quality.duplicates._LENGTH_TOLERANCE_ABS |
2 | backend/app/domain/quality/duplicates.py:23 |
app.domain.quality.duplicates._MATRIX_MIN_PAIRS |
8000000 | backend/app/domain/quality/duplicates.py:297 |
Correspondência entre datasets
O que mede. Quanto dois datasets (ex.: clientes no CRM e no ERP) concordam
sobre quem é quem — pares prováveis, linhas sem par e ambíguas
(backend/app/domain/quality/matching.py). Fora do scan, só a pedido; nunca funde.
Método (matching.py:622-690):
- normalização por chave:
email(lower/strip),nif(exatamente 9 dígitos, senão vazio; um prefixo de país que não éPT, comoGBouCHE, ou o EINNN-NNNNNNNtambém dão vazio),name(sem acentos nem pontuação),postal_code/digits(só dígitos),text(lower sem acentos);autoescolhe pelo nome da coluna.phone_ptdá E.164 no plano de numeração do país dos dados de cada lado (phones.normalize_phone, com ophonenumbers, cartão #516): fora de PT, «912 345 678» é+34912345678num dataset espanhol, «015 11 91234-5678» é+5511912345678num brasileiro e «1-212-555-0123» é+12125550123nos EUA, e nunca se acrescenta o +351. Com o país PT (ou sem país) decide-se pela chave inteira (phones.phone_normalizer):- sem indicativos estrangeiros na chave, dígitos com o +351 nos 9 dígitos;
- com indicativos estrangeiros, um número
nsem indicativo recebe ocdo único+c nda chave; - com vários
+c n, recebe o indicativo dominante (dominant_calling_code: o que mais vezes liga umna um+c n), quando é um deles; - sem ligação, o +351 aplica-se aos 9 dígitos;
- candidatos: chaves
exact/normalizedpor índice invertido (join por hash); valores que se repetem mais de 50 vezes num lado não geram candidatos (lixo como «n/a»); chavesfuzzyportoken_sort_ratiodentro de blocos (block_value: 4 dígitos do código postal; 3 primeiros caracteres de email, telefone ou NIF; nas outras colunas, os 3 primeiros do token mais longo, sem acentos — desde o #352, antes os 3 primeiros do valor inteiro); sem blocking só até 1 000 000 de pares; - pontuação = média ponderada das chaves presentes nos dois lados
(
exact/normalizedvalem 1 ou 0;fuzzyvale a razão 0–1 se ≥thresholdda chave, senão 0;matching.py:609-619); chaves vazias num lado não entram no denominador; - decisão: par quando
score ≥ min_score(0,85); uma linha é ambígua quando tem ≥ 2 candidatos a menos deambiguity_epsilon(0,02) do melhor;pairsleva o melhor candidato de cada linha de A. Cobertura = linhas com ≥ 1 candidato com pontuação ≥min_score÷ total (matching.py:534-536, 565-566) — uma linha cujos candidatos ficam todos abaixo de 0,85 conta como sem cobertura; a cobertura mínima por omissão para o run ficar «ok» é 80 %.
Exato sobre as linhas lidas (até matching_max_rows por lado, só colunas-chave).
Quando um lado enche o teto (truncated_a/truncated_b), o resumo leva
estimated=true e a cobertura desse lado o intervalo de Wilson a 95 %
(coverage_a_ci95/coverage_b_ci95, pontos percentuais; coverage_estimate em
backend/app/domain/quality/matching_service.py; cartão #350). Com o outro lado
cortado, a cobertura é um mínimo (o par pode estar nas linhas não lidas) e fica sem
intervalo, que não mede esse enviesamento. As linhas lidas são as primeiras que a
fonte devolve, não uma amostra aleatória: sobre elas a cobertura é exata, e o
intervalo só vale para a tabela se essas linhas a representarem.
A sobrevivência (que registo «ficaria») é apenas uma proposta: mais completo,
depois mais recente, depois o lado A.
Benchmark (cartão #352, o mesmo gerador dos duplicados): CRM × ERP com 70 % das entidades nos dois lados (o lado B com 1–2 perturbações e o nome em maiúsculas sem acentos metade das vezes), 15 % só em cada lado e 5 % das partilhadas duas vezes no ERP. Com 10 000 linhas:
- spec sugerida (
suggest_keys: NIF, email, telefone, nome fuzzy, código postal; blocking pelo código postal), limiar emin_score0,85: precisão 1,0, recall 0,868, F1 0,929; o melhor da grelha é 0,934 a 0,80 — sem evidência para mudar; - só a chave do nome: o blocking pelo token mais longo deixa chegar 94,8 % dos pares verdadeiros (77,1 % com os 3 primeiros caracteres do nome inteiro) e o F1 a 0,85 sobe de 0,527 para 0,593. O nome sozinho fica abaixo de 0,62 em todo o lado: os homónimos são indistinguíveis sem outra chave;
ambiguity_epsilon0,02: nenhum par errado na spec sugerida; marca 120 linhas ambíguas, os clientes que o ERP tem duas vezes (0,05 marca 128).
| constante | valor | referência |
|---|---|---|
app.domain.quality.matching.MAX_UNBLOCKED_PAIRS |
1000000 | backend/app/domain/quality/matching.py:73 |
app.domain.quality.matching.MAX_KEYS |
10 | backend/app/domain/quality/matching.py:74 |
app.domain.quality.matching.MAX_BLOCKING |
3 | backend/app/domain/quality/matching.py:75 |
app.domain.quality.matching.MAX_BUCKET |
50 | backend/app/domain/quality/matching.py:78 |
app.domain.quality.matching.DEFAULT_MIN_SCORE |
0.85 | backend/app/domain/quality/matching.py:79 |
app.domain.quality.matching.DEFAULT_THRESHOLD |
0.85 | backend/app/domain/quality/matching.py:80 |
app.domain.quality.matching.DEFAULT_MIN_COVERAGE_PCT |
80.0 | backend/app/domain/quality/matching.py:81 |
app.domain.quality.matching.DEFAULT_AMBIGUITY_EPSILON |
0.02 | backend/app/domain/quality/matching.py:82 |
app.domain.quality.matching.BLOCK_PREFIX_CHARS |
3 | backend/app/domain/quality/matching.py:83 |
app.domain.quality.matching.BLOCK_POSTAL_DIGITS |
4 | backend/app/domain/quality/matching.py:84 |
app.domain.quality.pt_shape.PT_SHAPE_MAJORITY |
0.5 | backend/app/domain/quality/pt_shape.py:39 |
Regras configuráveis e clusters (cartão #387)
O que mede. O mesmo que a correspondência acima, com regras guardadas por par de datasets:
que linhas de A e de B são a mesma entidade, agrupadas em clusters que se reveem na app
(backend/app/domain/quality/matching_rules.py). Corre fora do scan, na fila do matching, sobre
uma amostra determinística de até 50 000 linhas de cada lado (matching_max_rows): sample_rows
com a semente stable_seed do id do dataset, a mesma de execução para execução
(amostragem). O resumo do run diz o método de amostragem de cada lado.
Comparadores por campo (backend/app/domain/quality/matching_comparators.py). Cada um
devolve uma semelhança entre 0 e 1, ou nada quando falta o valor num dos lados:
exact: igualdade do texto como vem, só sem os espaços das pontas;normalized: igualdade depois do normalizador do campo (os da correspondência acima: email, telefone, NIF, nome, código postal, dígitos e texto);jaro_winkler: semelhança Jaro–Winkler dorapidfuzzsobre o valor normalizado, que dá um bónus ao prefixo comum;token:token_sort_ratiodorapidfuzz, o mesmo das chaves aproximadas, que aceita a troca de ordem das palavras («Silva, João» e «João Silva»);numeric: 1 se |a − b| ≤tolerance, senão 0; aceita vírgula ou ponto decimal;date: 1 se as duas datas distam no máximowindow_daysdias, senão 0; lê ISO e dd/mm/aaaa.
Em jaro_winkler e token, uma semelhança abaixo do threshold do campo (0,85 por omissão)
conta 0.
Score do par = média ponderada das semelhanças dos campos presentes nos dois lados. Um campo vazio num dos lados não entra no denominador. A explicação guarda a semelhança de cada campo e nunca os valores.
Candidatos. Nunca se comparam todos os pares às cegas: os candidatos são a união dos blocos
de cada regra de blocking e dos joins por hash dos campos exact e normalized. A chave de bloco
é o valor normalizado inteiro (key), os N primeiros caracteres (prefix: num nome ou num texto
sem tipo, do token mais longo; nas outras colunas, do valor normalizado, que no código postal e no
telefone são só os dígitos) ou o código fonético do token mais longo (phonetic). Um bloco com
mais de 200 linhas num dos lados, ou um valor de igualdade com mais de 50, não gera candidatos e
fica contado. Sem nenhum gerador, as regras só correm até 1 000 000 de pares.
Código fonético (phonetic_pt). Soundex e Metaphone foram feitos para a ortografia inglesa.
O phonetic_pt é uma redução própria das regras de escrita do português, e não um algoritmo
publicado: «ph» → f, «ch» e «sh» → x, «lh» → l, «nh» → n, «qu» e «gu» antes de e/i → k e g, «c»
antes de e/i → s, «g» antes de e/i → j, «s» entre vogais → z, «w» → v, «y» → i, «z» final → s,
«h» mudo e letras repetidas fundidas. Por fim, saem as vogais depois da primeira letra, e o código
fica com até 6 caracteres. «Sousa» e «Souza» dão sz, e «Gonçalves» e «Goncalves» dão gnklvs.
Sem as vogais, o código é curto e junta nomes diferentes («Silva» e «Salvo» dão slv): serve para
gerar candidatos, e quem decide é o score.
Clusters. Cada linha de A liga-se ao seu melhor candidato com score ≥ 0,70, o limiar de revisão, e cada linha de B também (empate → menor índice). Os clusters são as componentes ligadas dessas ligações (union-find). Um cluster fica na faixa «correspondência» quando a ligação mais fraca tem score ≥ 0,85, o limiar de correspondência, e na faixa «revisão» nos outros casos. O score do cluster é o dessa ligação. Abaixo de 0,70 o par não fica guardado.
Assinatura e decisões. A assinatura do cluster é o sha256 das identidades das suas linhas,
ordenadas. A identidade é o hash da chave primária declarada ou, sem ela, dos valores comparados.
A decisão de revisão (fundir ou ignorar) liga-se ao par regras × assinatura: vale nas execuções
seguintes enquanto o cluster tiver as mesmas linhas, e um cluster que ganhe ou perca uma linha
volta a «por rever». A precisão observada é a dos clusters da faixa «correspondência» do
último run que alguém reviu: os fundidos ÷ os revistos, e nenhuma enquanto não houver revisões
(backend/app/domain/quality/matching_review_service.py). Mede quanto a faixa acerta nos dados do
cliente, mas só sobre o que foi revisto.
Sobrevivência (backend/app/domain/quality/survivorship.py). Em cada cluster, cada campo
fica com o valor de um dos registos, pela regra do campo ou pela regra por omissão:
source_priority (o primeiro valor não vazio pela ordem das fontes), most_recent (o do registo
com a coluna de atualização mais recente; exige a coluna de atualização dos dois datasets, que a
sugestão deteta pelo nome como na v1, e uma linha sem data conta como a mais antiga), most_complete (o
do registo com mais campos preenchidos, a regra por omissão) ou most_frequent (o valor mais
frequente no cluster, comparado normalizado). Os empates resolvem-se pela ordem das fontes e,
dentro de cada fonte, pela das linhas. Cada campo leva a proveniência: o lado, a identidade da
linha e a regra. O registo consolidado é só proposto. O export é um SELECT (consolidation_sql,
conferido por assert_read_only_sql) ou um DataFrame PySpark que termina em .show()
(consolidation_pyspark, conferido por assert_read_only_pyspark).
Benchmark (cartão #352, o gerador CRM × ERP acima). As regras são as chaves sugeridas para CRM
× ERP, convertidas por from_v1_suggestion: NIF (peso 3), email (2), telefone (1) e código postal
(0,5) com normalized, e o nome (2) com token. O blocking é pelos 4 primeiros dígitos do código
postal e os limiares são os por omissão. A precisão e o recall medem-se por pares sobre os clusters
da faixa «correspondência»:
| linhas | precisão | recall | F1 |
|---|---|---|---|
| 2 000 | 1,0 | 0,83 | 0,91 |
| 10 000 | 1,0 | 0,85 | 0,92 |
backend/tests/test_matching_rules.py exige precisão ≥ 0,9 e recall ≥ 0,8 nos dois tamanhos e
recalcula os números de 10 000 linhas que a API devolve nas métricas (BENCHMARK). O phonetic
e o jaro_winkler não entram nestas regras e ficam sem medição no benchmark.
Limitações.
- Só vê pares dentro da amostra de cada lado, e o cálculo corre no worker do DataVault, sem pushdown para a fonte.
- Cada conjunto de regras compara dois datasets.
- Os limiares só mudam à mão: as decisões de revisão dão a precisão observada, mas não afinam os limiares.
- Um código postal ou um prefixo muito comum enche o bloco (mais de 200 linhas num lado) e os pares desse bloco perdem-se, se nenhuma outra regra os gerar.
- Cada run guarda até 5 000 clusters, os de revisão primeiro e depois os de score mais baixo, e só os primeiros 500 levam valores de exemplo. As contagens do resumo contam todos os clusters.
- No SQL exportado, o JOIN usa só os campos
exactenormalized, e onormalizedfica aproximado porLOWER(TRIM(…)). Os pares que dependem de campos de semelhança estão nos clusters da app. Cada linha do export é um par A × B e leva, campo a campo, o valor queconsolidateescolhe para esse par, com os mesmos empates:most_recentemost_completetêm expressão própria nos dois, e com dois valoresmost_frequentempata e fica a prioridade das fontes. Num cluster com mais de dois registos, a escolha entre todos só está na app. O export só trataNULLcomo vazio; a app também trata o texto em branco.
| constante | valor | referência |
|---|---|---|
app.domain.quality.matching_rules.DEFAULT_MATCH_THRESHOLD |
0.85 | backend/app/domain/quality/matching_rules.py:50 |
app.domain.quality.matching_rules.DEFAULT_REVIEW_THRESHOLD |
0.7 | backend/app/domain/quality/matching_rules.py:51 |
app.domain.quality.matching_rules.DEFAULT_FIELD_THRESHOLD |
0.85 | backend/app/domain/quality/matching_rules.py:52 |
app.domain.quality.matching_rules.DEFAULT_PREFIX_LENGTH |
3 | backend/app/domain/quality/matching_rules.py:53 |
app.domain.quality.matching_rules.MAX_FIELDS |
12 | backend/app/domain/quality/matching_rules.py:54 |
app.domain.quality.matching_rules.MAX_BLOCKING_RULES |
4 | backend/app/domain/quality/matching_rules.py:55 |
app.domain.quality.matching_rules.MAX_BLOCK_ROWS |
200 | backend/app/domain/quality/matching_rules.py:56 |
app.domain.quality.matching_rules.MAX_WINDOW_DAYS |
3650 | backend/app/domain/quality/matching_rules.py:58 |
app.domain.quality.matching_comparators.PHONETIC_LENGTH |
6 | backend/app/domain/quality/matching_comparators.py:63 |
app.domain.quality.matching_rules_service.MAX_STORED_CLUSTERS |
5000 | backend/app/domain/quality/matching_rules_service.py:68 |
app.domain.quality.matching_rules_service.MAX_STORED_SAMPLES |
500 | backend/app/domain/quality/matching_rules_service.py:69 |
Profiling profundo
O que mede. Com profile_depth=standard: 5 quantis e um histograma por coluna
numérica; com deep: correlações de Pearson e co-nulidade. O domínio escolhe as
colunas (backend/app/domain/quality/distributions.py); o conector SQL calcula por
pushdown sem trazer linhas (backend/app/infra/connectors/sql.py:1789-1843); fontes
sem pushdown calculam sobre a amostra (method="sample").
Quantis (p05, p25, p50, p75, p95; backend/app/infra/connectors/base.py:120).
O método depende do dialeto (sql.py:2263-2278):
| Dialeto | Método | Natureza e erro |
|---|---|---|
| PostgreSQL | percentile_cont |
exato (interpolação linear entre os dois valores vizinhos) |
| BigQuery | APPROX_QUANTILES(x, 20) |
aproximado; 21 fronteiras, usa-se a fronteira round(fração · 20); erro conforme o fornecedor |
| Snowflake, Databricks | APPROX_PERCENTILE(x, q) |
aproximado (t-digest do fornecedor); erro conforme o fornecedor |
| MySQL, SQLite, SQL Server, Fabric, outros | NTILE(20) + máximo por bucket (sql.py:2335-2348, backend/app/infra/connectors/profiling.py:321-335) |
aproximado por posição: a resposta é o maior valor do bucket de 5 % mais próximo (nearest-rank com resolução 1/20) — o p95 devolvido é o máximo do 19.º bucket, isto é, o valor na posição ~95 %; com menos de 20 linhas o índice é reescalado ao número real de buckets; guardado com estimated=false (ver abaixo) |
| Sem pushdown (ficheiros, APIs) | interpolação linear sobre a amostra (distributions.py:124-130) |
amostrado |
estimated=true fica registado no perfil para APPROX_QUANTILES/APPROX_PERCENTILE
(sql.py:1813, _APPROX_METHODS em sql.py:2269) e para a amostra
(distributions.py:146). A interface não lê essa flag: mostra um badge por
method, um por tabela e não por quantil, na secção «Distribuições &
Correlações» (frontend/src/components/ProfileDistributions.tsx:7-13) — «Exato —
calculado na fonte (percentile_cont)», «Aproximado — calculado na fonte
(APPROX_QUANTILES | APPROX_PERCENTILE)», «Calculado na fonte por NTILE (resolução
de 5%)» e, a âmbar e com o número de linhas, «Estimado por amostra».
Limitação conhecida: os quantis por NTILE(20) são aproximados por posição
mas ficam guardados com estimated=false; a flag não os distingue dos exatos —
só o method («ntile») o revela. Na interface o badge diz a resolução de 5 %,
mas não a palavra «estimado»; pela API ou numa exportação, quem só olhar para
estimated toma-os por exatos.
Métodos por ordenação (percentile_cont, NTILE) são saltados acima de
profile_quantiles_max_rows linhas (sql.py:1819) — a coluna fica sem quantis
em vez de esperar por uma ordenação de 25 M de linhas.
Histogramas (profiling.py:294-318): 10 buckets de largura igual entre o
mínimo e o máximo do perfil; colunas inteiras com amplitude ≤ 10 usam buckets
unitários; o último bucket é fechado à direita. Contagens por SUM(CASE …) numa
query por lote (sql.py:1893-1944) — exatas no pushdown, amostradas sem ele.
Limitação: largura igual com um outlier esmaga a distribuição num só bucket.
Correlações (deep; distributions.py:93-107, sql.py:1946-1995). Pearson
entre ≤ 10 colunas numéricas com amplitude, mais de 2 distintos e que não sejam
chaves (nome id/_id/pk/key/uuid, ou inteiros todos distintos numa
sequência contígua — máx − mín + 1 = distintos = não nulos, a começar em
qualquer inteiro; distributions.py:52-68);
todos os pares numa só query, com CORR nativo em PostgreSQL/BigQuery/Snowflake/
Databricks ou pela fórmula das somas (profiling.py:338-350). Cada par traz o n,
as linhas com as duas colunas preenchidas (no CORR nativo, um SUM(CASE …) na
mesma query; correlation_exprs em sql.py). Desde o cartão #350, nos dois
caminhos (significant_correlations em distributions.py), um par só aparece com
n ≥ 30 (MIN_N_CORRELATION em backend/app/domain/quality/stats.py) e quando
o intervalo de confiança a 95 % de Fisher (fisher_r_interval: tanh(atanh(r) ± 1,96/√(n − 3))) exclui o zero; o intervalo fica no par (ci95). Porquê 30:
sem relação nenhuma entre as colunas, com n = 3 um |r| ≥ 0,5 sai em 2/3 dos pares.
Depois reportam-se os pares com |r| ≥ profile_correlation_min_abs (0,5 por
omissão, configurável) — não é uma constante de domínio, é uma definição de
reporte. Sobre a amostra usa-se statistics.correlation e junta-se o Spearman
(Pearson sobre as ordens, empates com a ordem média; spearman em
distributions.py), que apanha relações monótonas não lineares; no pushdown fica só
o Pearson com o n. Pearson mede relação linear; NULLs são excluídos par a par;
o intervalo de Fisher assume normalidade bivariada; não há correção de
multiplicidade entre os até 45 pares. Perfis gravados antes do cartão #350 guardam
os pares sem n até ao scan deep seguinte.
Co-nulidade (deep; distributions.py:110-118, 189-198,
base.py:139-146): entre as colunas com mais NULLs (excluindo as 100 % vazias),
Jaccard = ambas_nulas / (nulas_a + nulas_b − ambas_nulas); reportado quando
≥ profile_co_null_min_jaccard (0,5 por omissão, configurável). Exato no
pushdown.
| constante | valor | referência |
|---|---|---|
app.infra.connectors.base.QUANTILE_KEYS[0.05] |
p05 | backend/app/infra/connectors/base.py:208 |
app.infra.connectors.base.QUANTILE_KEYS[0.95] |
p95 | backend/app/infra/connectors/base.py:208 |
app.infra.connectors.base.HISTOGRAM_BUCKETS |
10 | backend/app/infra/connectors/base.py:209 |
app.infra.connectors.profiling.NTILE_BUCKETS |
20 | backend/app/infra/connectors/profiling.py:26 |
app.infra.connectors.sql._QUANTILE_METHODS["postgresql"] |
percentile_cont | backend/app/infra/connectors/sql.py:2108 |
app.infra.connectors.sql._QUANTILE_METHODS["bigquery"] |
approx_quantiles | backend/app/infra/connectors/sql.py:2625 |
app.infra.connectors.sql._QUANTILE_METHODS["snowflake"] |
approx_percentile | backend/app/infra/connectors/sql.py:2624 |
app.infra.connectors.sql._QUANTILE_METHODS["databricks"] |
approx_percentile | backend/app/infra/connectors/sql.py:2626 |
app.infra.connectors.sql._BQ_QUANTILE_SLOTS |
20 | backend/app/infra/connectors/sql.py:2902 |
app.config.settings.Settings.model_fields["profile_distribution_max_columns"].default |
20 | backend/app/config/settings.py:945 |
app.config.settings.Settings.model_fields["profile_correlation_max_columns"].default |
10 | backend/app/config/settings.py:948 |
app.config.settings.Settings.model_fields["profile_quantiles_max_rows"].default |
25000000 | backend/app/config/settings.py:952 |
app.config.settings.Settings.model_fields["profile_deep_budget_s"].default |
60 | backend/app/config/settings.py:956 |
app.config.settings.Settings.model_fields["profile_correlation_min_abs"].default |
0.5 | backend/app/config/settings.py:959 |
app.domain.quality.stats.MIN_N_CORRELATION |
30 | backend/app/domain/quality/stats.py:26 |
app.config.settings.Settings.model_fields["profile_co_null_min_jaccard"].default |
0.5 | backend/app/config/settings.py:962 |
Distintos aproximados (HLL)
O que mede. O número de valores distintos por coluna em perfis incrementais
(datasets grandes, opt-in), onde recontar COUNT(DISTINCT) sobre a tabela inteira
é o custo que se quer evitar. Nos perfis completos os distintos são exatos
(COUNT(DISTINCT), saltado acima de profile_distinct_max_rows).
Método (backend/app/infra/connectors/hll.py): HyperLogLog (Flajolet, Fusy,
Gandouet & Meunier, 2007) com hash de 32 bits e precisão p = 12, isto é,
m = 2^12 = 4096 registos por coluna; erro padrão relativo ≈ 1.04/√m ≈ 1.6 %.
O sketch é construído na fonte por GROUP BY hash % 4096 com MIN(hash) por
registo (portável), fundido entre janelas por máximo por registo, e estimado com
as correções clássicas: linear counting quando a estimativa bruta é ≤ 2,5·m e há
registos a zero (cardinalidades pequenas são quase exatas), e correção de gama alta
acima de 2^32/30 (hll.py:81-96). A coluna fica marcada distinct_estimated e a
interface diz «estimado».
Hash por dialeto (_hash_expr, _value_text e _hash_text,
backend/app/infra/connectors/sql.py:2401-2513, cartão #317): o texto do valor
(COALESCE(CAST(col AS texto), marcador de NULL)) passa por hashtext (PostgreSQL, +2³¹), CRC32 (MySQL), dv_hash (SQLite,
blake2b), FARM_FINGERPRINT (BigQuery), HASH (Snowflake), xxhash64
(Databricks) e os primeiros 4 bytes de HASHBYTES('SHA2_256') (SQL Server sobre
NVARCHAR, Fabric sobre VARCHAR — família própria, fabric; o CHECKSUM
seguia a collation). Os de 64 bits passam a 32 por & 2³²−1
(BITAND no Snowflake) antes de qualquer soma. O GROUP BY dos sketches
repete MOD(h, 4096) com o número escrito (sketch_stmt). Até ao cartão #317 o
marcador de NULL levava o carácter NUL, que o psycopg2 recusa: no PostgreSQL os
sketches saíam vazios em silêncio (e os checksums do data-diff falhavam).
O texto tem de ser fiel ao valor: um texto que perde informação dá o mesmo hash a
valores diferentes, e o checksum do data-diff diria «igual» sem o ser. Há duas
exceções ao CAST:
- BigQuery. ARRAY, STRUCT, JSON e GEOGRAPHY não têm
CASTparaSTRING, e um BYTES com UTF-8 inválido falha. Esses tipos, e os desconhecidos, vão porTO_JSON_STRING. - SQL Server e Fabric. O
CASTusa o estilo 0, por isso o texto sai porCONVERTcom estilo explícito:datetime/smalldatetime(o estilo 0 tira os segundos) → 126, ISO 8601 com milissegundos;float/real(6 dígitos) → 3, os dígitos de ida e volta;money(2 decimais) → 2, os 4 decimais;- binários → hexadecimal, pelo estilo 1.
Nos checksums, um lote recusado volta coluna a coluna. A coluna que a fonte não
consegue escrever em texto (um tipo espacial, por exemplo) ou que passa um teto
fica sem checksum e é comparada pela amostra (checksum.method = "sample"). O
data-diff não falha por isso, e None nunca conta como «igual».
Limitações. PostgreSQL, MySQL, SQLite, BigQuery, Snowflake, Databricks e SQL Server (outros dialetos ficam sem distintos no perfil incremental, dito na interface); o hash de 32 bits degrada a estimativa acima de ~10^8 distintos; o sketch nunca guarda valores.
| constante | valor | referência |
|---|---|---|
app.infra.connectors.hll.HASH_BITS |
32 | backend/app/infra/connectors/hll.py:24 |
app.infra.connectors.hll.PRECISION |
12 | backend/app/infra/connectors/hll.py:26 |
app.config.settings.Settings.model_fields["profile_distinct_max_rows"].default |
25000000 | backend/app/config/settings.py:881 |
app.config.settings.Settings.model_fields["profile_incremental_min_rows"].default |
10000000 | backend/app/config/settings.py:930 |
app.config.settings.Settings.model_fields["profile_incremental_full_every"].default |
7 | backend/app/config/settings.py:937 |
Tipos semânticos
O que mede. O tipo provável de cada coluna (email, NIF, IBAN, código postal,
telefone, data, categoria, identificador…) e a confiança nessa classificação
(backend/app/domain/quality/semantic_types.py), sem LLM.
Método. O sinal principal são os até 20 sample_values distintos do perfil
(Amostragem): um padrão ou lista fechada tem de casar com
≥ 80 % dos distintos não nulos (MAJORITY); os tipos são testados por ordem de
especificidade (dígito de controlo NIF/IBAN/NISS/CC → padrões → listas →
agregados), com reforço pelo nome da coluna e pelo tipo físico.
Para os tipos com padrão ou lista (semantic_types.py:446-460):
confidence = base_do_tipo · fração_que_casa · (0.8 + 0.2 · min(1, n/5)) + 0.05 se o nome ajudar, limitada a 0,99 — com menos de 5 amostras a confiança desce até
20 %; em phone_pt, phone, country_iso2 e currency_iso3 a pista no nome substitui a
base por 0,9 em vez de somar o bónus (semantic_types.py:287-307). O phone fora
de PT (cartão #516) não conta os valores com parte decimal. Sem pista no nome, só conta
os escritos com «+», «00» ou separadores (phones.has_phone_mark) e agrupados como um
telefone do plano (phones.is_grouped_phone, o Leniency.STRICT_GROUPING do
phonenumbers), e uma coluna de tipo numérico nativo não é phone
(semantic_types._phone_type): os planos alemão e francês dão como atribuídos muitos
inteiros de 6 a 9 dígitos, e o angolano, sem 0 de tronco, o processo «2024/12345».
Em backend/tests/test_phones.py, 40 colunas de 20 códigos com separadores
(processos AO e DE, SIREN FR) não saem phone. Também sem pista no nome, 40 colunas
de telefones formatados pelo phonenumbers em cada um de 8 países (ES, BR, AO, US,
CH, GB, FR, DE) continuam phone. Os restantes
caminhos não usam esta fórmula: boolean_like pelo tipo físico vale 0,99
(semantic_types.py:240-241); person_name e address valem base · fração
sem fator de suporte nem bónus (semantic_types.py:329-335); os agregados
identifier, category e free_text têm confiança fixa, a base do tipo
(semantic_types.py:338-353). valid_pct é a fração dos distintos da
amostra que casam — não a fração das linhas. Desde o cartão #350 o tipo leva
também sample_size, quantos distintos foram medidos, e estimated; a interface e
o mapa de PII mostram «N/M valores da amostra» (sample_matches no PIIMAP) em vez
da percentagem, porque «100 %» sobre 5 valores e sobre 20 não dizem o mesmo.
sample_size fica vazio quando o tipo não sai da amostra: booleano pelo tipo
físico, identifier, category e money pelo nome da coluna. A classificação de
PII usa os tipos semânticos a partir de confiança 0,8
(backend/app/domain/compliance/pii_map.py:143).
Limitações. 20 valores distintos de uma amostra de 1 000 linhas (os mesmos
de scan para scan): uma coluna com 1 % de emails mal formados pode aparecer com
valid_pct=1.0; a regra pattern sobre as 5 000 linhas lidas é o instrumento
para medir a validade com mais base —
valid_identifier e in_reference_set avaliam os mesmos 20 distintos do perfil
(ver Amostragem), salvo a in_reference_set com uma lista do
registo do workspace, que avalia as linhas lidas.
| constante | valor | referência |
|---|---|---|
app.domain.quality.semantic_types.MAJORITY |
0.8 | backend/app/domain/quality/semantic_types.py:61 |
app.domain.quality.semantic_types.FULL_SUPPORT_SAMPLES |
5 | backend/app/domain/quality/semantic_types.py:68 |
app.domain.quality.semantic_types.MIN_FORMAT_SAMPLES |
3 | backend/app/domain/quality/semantic_types.py:77 |
app.domain.quality.semantic_types.NAME_BONUS |
0.05 | backend/app/domain/quality/semantic_types.py:229 |
app.domain.quality.semantic_types.PHONE_NAME_CONFIDENCE |
0.9 | backend/app/domain/quality/semantic_types.py:230 |
app.domain.quality.semantic_types.LIST_NAME_CONFIDENCE |
0.9 | backend/app/domain/quality/semantic_types.py:231 |
app.domain.quality.semantic_types._BASE_CONFIDENCE["identifier"] |
0.8 | backend/app/domain/quality/semantic_types.py:216 |
app.domain.quality.semantic_types._BASE_CONFIDENCE["category"] |
0.6 | backend/app/domain/quality/semantic_types.py:225 |
app.domain.quality.semantic_types._BASE_CONFIDENCE["free_text"] |
0.5 | backend/app/domain/quality/semantic_types.py:225 |
app.domain.compliance.pii_map.PII_MIN_SEMANTIC_CONFIDENCE |
0.8 | backend/app/domain/compliance/pii_map.py:143 |
Qualidade de rótulos
O que mede. A percentagem de linhas de um dataset de treino cujo rótulo
discorda do que as restantes features sugerem — confident learning (Northcutt,
Jiang & Chuang, 2021) via cleanlab, opt-in pelo extra [labelq]
(backend/app/domain/quality/label_quality.py).
Método (label_quality.py:253-369):
- amostra: até
label_quality_max_rowslinhas do rótulo e das features, lidas com a amostra por dialeto e a semente do dataset (backend/app/domain/quality/label_quality_service.py:318; cartão #317 — até aí eram as primeiras N linhas porLIMIT). A estratificação em memória por classe (stratified_sample,label_quality.py:137-152) só reduz quando recebe mais linhas do quemax_rows, e o serviço passa o mesmo limite à leitura e à análise, por isso neste caminho não altera nada: a amostra é a do conector, e o resumo e o issue dizem qual (sample); - só classificação: rótulos numéricos com > 50 classes distintas, ou fracionários com > 10, são recusados como regressão; classes com menos exemplos do que folds ficam de fora (reportadas); mínimo 50 linhas e 2 classes;
- encoding: numéricas tal como estão (NaN para vazios), categóricas em one-hot das 20 categorias mais frequentes; colunas quase todas distintas são descartadas;
- modelo
hgb(HistGradientBoostingClassifier, 100 iterações) oulogreg, com validação cruzada estratificada de 5 folds (baralhada, semente 0) para obter probabilidades out-of-fold por linha; cleanlab.filter.find_label_issuesordena as linhas suspeitas por self-confidence;pct_suspect = suspeitas / n_amostra.
Intervalo (cartão #350; label_estimate em label_quality.py). Quando a
leitura cobriu a tabela inteira (sample.covers_table), o número é da tabela:
estimated=false e pct_suspect_ci95 vazio. Com amostra, o resumo traz
estimated=true, sample_size = n_amostra e pct_suspect_ci95, o intervalo de
Wilson a 95 % de suspeitas / n_amostra em pontos percentuais. Quando a amostra diz
a fração lida (sample.fraction), o intervalo leva a correção de população finita,
com população n_amostra / fraction; sem ela, fica sem correção, o mais largo dos
dois.
Escala na prontidão para IA (label_quality_component em label_quality.py):
rotulos = clamp(100 − 5 · p, 0, 100) — 2 % suspeitos → 90, 10 % → 50, ≥ 20 % → 0.
A escala é deliberadamente severa: ruído acima de ~10 % já degrada a maioria dos
modelos de forma visível. Desde o cartão #350, quando a análise correu sobre uma
amostra, p é o limite inferior do intervalo a 95 %, não pct_suspect: só o
ruído que a amostra sustenta baixa o score. Com 100 suspeitas em 1 000 linhas sem
fração conhecida, o intervalo é [8,29 %; 12,02 %] e a dimensão vale 58,6 em vez de 50;
com 5 em 50 vale 78,2. É conservador em afirmar que os rótulos estão maus, e por isso
sobe os scores dos datasets medidos sobre amostra. Lida a tabela inteira, não há
intervalo e p = pct_suspect, como antes do cartão. Resumos gravados antes do
cartão recebem o intervalo calculado das contagens e da amostra gravada, e ficam sem
ele quando ela cobriu a tabela (label_quality_summary em
ai_readiness_service.py).
Limitações. É uma estimativa dependente do modelo barato usado; rótulos «suspeitos» são candidatos a revisão humana, não erros confirmados; as features e probabilidades morrem com a chamada (só o resumo e ≤ 10 exemplos truncados, sem colunas PII, persistem).
| constante | valor | referência |
|---|---|---|
app.domain.quality.label_quality.DEFAULT_MAX_ROWS |
50000 | backend/app/domain/quality/label_quality.py:38 |
app.domain.quality.label_quality.DEFAULT_TOP_K |
20 | backend/app/domain/quality/label_quality.py:39 |
app.domain.quality.label_quality.DEFAULT_CV_FOLDS |
5 | backend/app/domain/quality/label_quality.py:40 |
app.domain.quality.label_quality.MAX_EXAMPLES |
10 | backend/app/domain/quality/label_quality.py:41 |
app.domain.quality.label_quality.MAX_CLASSES |
50 | backend/app/domain/quality/label_quality.py:44 |
app.domain.quality.label_quality.MIN_ROWS |
50 | backend/app/domain/quality/label_quality.py:45 |
Auditoria de exatidão
O que mede. A fração de registos corretos de um dataset, confirmada por
pessoas. Sem fonte autoritativa, nenhum check automático mede a exatidão; a
auditoria estima-a a partir de uma amostra revista à mão (cartão #267,
backend/app/domain/quality/accuracy_audit.py e
backend/app/domain/quality/accuracy_audit_service.py).
Método.
- Leitura: até
POOL_MAX_ROWSlinhas (ou o teto de amostragem da conexão) com a amostra por dialeto e uma semente própria da auditoria (accuracy_audit_service.py:151-207). O método da leitura fica no desenho da auditoria e a API devolve-o (design.sample_method). - Tamanho (
required_sample_size,accuracy_audit.py:110-124): fórmula de Cochran para uma proporção, com a pior hipótese p = 0,5:n₀ = z² · 0,25 / e². Com a população N conhecida, a correção finita dán = n₀ / (1 + (n₀ − 1) / N). A 95 % de confiança, 5 % de margem pedem 385 registos (278 numa tabela de 1000 linhas) e 3 % pedem 1068. O trabalho pedido a pessoas tem o tetoMAX_SAMPLE_SIZE, e a amostra nunca passa das linhas lidas. Quando fica abaixo do exigido, o painel avisa que o intervalo vai ser mais largo do que a margem pedida. - Estratos (
build_strata,allocate,accuracy_audit.py:133-207): os valores de uma coluna escolhida dividem as linhas lidas. Acima deMAX_STRATAvalores, a cauda junta-se em «(outros)». A repartição é proporcional (maior resto), com pelo menosMIN_PER_STRATUMregistos por estrato. Quando a coluna de estratos tem dados pessoais e ninguém confirmou vê-los, os estratos chamam-se «estrato 1…k» (neutral_names,accuracy_audit.py:157-169) e a correspondência não fica guardada. - Escolha (
select_sample,accuracy_audit.py:210-220): ao acaso dentro de cada estrato, com a semente da auditoria. A mesma semente dá os mesmos registos. - Revisão: cada registo fica correto, incorreto (com motivo) ou não verificável. Os não verificáveis saem da estimativa. As colunas com dados pessoais aparecem mascaradas salvo confirmação explícita, que fica registada com quem e quando.
- Estimativa (
estimate_accuracy,accuracy_audit.py:289-345): média estratificadaΣ W_h · p_h, com variânciaΣ W_h² · (1 − n_h / N_h) · p_h (1 − p_h) / (n_h − 1). O intervalo é o de Wilson sobre o tamanho efetivo de Kish,n_eff = p (1 − p) / Var. Um estrato com uma só resposta entra com a variância máxima (0,25). Quando a amostra é a população inteira, a exatidão é conhecida e não há intervalo. - Veredito (
verdict_for,accuracy_audit.py:348-353): cumpre o alvo quando o limite inferior do intervalo o atinge; abaixo do alvo quando o limite superior fica aquém; inconclusivo quando o alvo está dentro do intervalo. - Comparação (
compare_estimates,accuracy_audit.py:364-383): diferença face à auditoria concluída anterior, com intervalo pela aproximação normal. Só há melhoria ou degradação quando esse intervalo não contém o zero.
A que linhas se aplica (_frame, accuracy_audit_service.py:134-148).
Quando a leitura foi a tabela inteira, a população é essa e o resultado é exato
para a tabela. Numa amostra aleatória da tabela inteira (TABLESAMPLE, hash da
chave, ou passo num ficheiro lido até ao fim), a população é o row_count do
último scan. Em dois casos as linhas lidas não são uma amostra da tabela, e a
população passa a ser essas linhas:
- a fonte só devolve as primeiras linhas (o modo
head, o dos conectores de API e SaaS); - a leitura de um ficheiro parou no teto do conector e o ficheiro continuava
(
RowSample.rows_read, no CSV local e no armazenamento de objetos: S3, GCS, ADLS e SFTP). O passo só se espalha pelas linhas lidas, e orow_countconta o ficheiro inteiro.
A auditoria guarda então scope = rows_read, e o painel, a evidência do trust e
o aviso accuracy_below_target dizem que a estimativa só vale para essas
linhas. Num ficheiro cortado, dizem até que linha o ficheiro foi lido
(design.read_cut, params.file_read_cut).
Onde entra. Na dimensão Exatidão do scorecard, quando o último scan não tem checks de exatidão: score = exatidão estimada × 100, checks = registos revistos, aprovados = corretos. No trust, como evidência do componente de qualidade e, abaixo do alvo, como aviso com teto (ver Trust score).
Limitações. A estimativa vale à data da auditoria: a última concluída conta
até haver outra. Mede o que as pessoas conseguem confirmar; um registo que
ninguém consegue verificar não conta. Passado o prazo de retenção das amostras,
os valores dos registos e os nomes dos estratos são apagados
(purge_audit_samples, accuracy_audit_service.py:554-582); o veredito, o
motivo e a estimativa ficam.
| constante | valor | referência |
|---|---|---|
app.domain.quality.accuracy_audit.DEFAULT_CONFIDENCE |
0.95 | backend/app/domain/quality/accuracy_audit.py:47 |
app.domain.quality.accuracy_audit.DEFAULT_MARGIN |
0.05 | backend/app/domain/quality/accuracy_audit.py:48 |
app.domain.quality.accuracy_audit.MIN_MARGIN |
0.01 | backend/app/domain/quality/accuracy_audit.py:49 |
app.domain.quality.accuracy_audit.MAX_MARGIN |
0.2 | backend/app/domain/quality/accuracy_audit.py:50 |
app.domain.quality.accuracy_audit.DEFAULT_TARGET |
0.95 | backend/app/domain/quality/accuracy_audit.py:51 |
app.domain.quality.accuracy_audit.MAX_SAMPLE_SIZE |
1000 | backend/app/domain/quality/accuracy_audit.py:53 |
app.domain.quality.accuracy_audit.MIN_PER_STRATUM |
2 | backend/app/domain/quality/accuracy_audit.py:54 |
app.domain.quality.accuracy_audit.MAX_STRATA |
12 | backend/app/domain/quality/accuracy_audit.py:55 |
app.domain.quality.accuracy_audit_service.POOL_MAX_ROWS |
5000 | backend/app/domain/quality/accuracy_audit_service.py:63 |
Trust score
O que mede. «Quanto posso confiar neste dataset e porquê»: 0–100 e grade A–E,
composto de sete componentes com evidência e ação para subir
(backend/app/domain/quality/trust.py). Determinístico, sem LLM; sem scan não há
score (None).
Fórmula (trust.py:191-195, 215-226, 453-524):
pontos_c = peso_c · valor_c · 100 valor_c ∈ [0, 1]
raw = Σ pontos_c
final = min(raw, teto) teto = menor dos bloqueadores/avisos ativos
grade = A ≥ 85 · B ≥ 70 · C ≥ 55 · D ≥ 40 · E abaixo
| Componente | Peso | Valor (0–1) |
|---|---|---|
| quality | 0,30 | score de qualidade ÷ 100 |
| freshness | 0,15 | 1 se idade ≤ alvo; decai linearmente até 0 quando idade = 3 × alvo (trust.py:273-303); 0,5 sem medição ou sem alvo (nem confiança nem desconfiança). O alvo vem, por esta ordem, da regra freshness do contrato, do SLA do domínio ou da cadência de scan |
| contract | 0,15 | 1 com contrato ODCS ativo; 0,4 só proposto/observado; 0 sem contrato |
| lineage | 0,10 | 0 sem linhagem a montante; 0,7 com linhagem; + 0,3 com linhagem por coluna |
| documentation | 0,10 | 0,4 · README publicado + 0,2 · descrição + 0,4 · fração de colunas descritas |
| ownership | 0,10 | 1 se pertence a ≥ 1 domínio com owner; 0 caso contrário |
| sensitivity | 0,10 | 1 sem colunas PII; senão revistas / PII, menos 0,5 se há PII sem máscara na camada gold (limitado a 0) |
Tetos. Issue critical por resolver, ou mudança de schema breaking não
reconhecida há mais de 7 dias → bloqueado, teto 40. Dataset sem uso (tier
dead ou issue unused_dataset) → aviso, teto 60. Última auditoria humana de
exatidão abaixo do alvo (accuracy_below_target) →
aviso, com teto no limite superior do intervalo de confiança, em pontos
(trust.py:466-494): o trust não promete mais do que a exatidão plausível.
O teto só é reportado quando efetivamente cortou o raw.
Os pesos e as grades são fixos por produto; cada componente devolve a frase de evidência com os números usados, por isso a nota é auditável linha a linha.
| constante | valor | referência |
|---|---|---|
app.domain.quality.trust.TRUST_WEIGHTS["quality"] |
0.30 | backend/app/domain/quality/trust.py:31 |
app.domain.quality.trust.TRUST_WEIGHTS["freshness"] |
0.15 | backend/app/domain/quality/trust.py:32 |
app.domain.quality.trust.TRUST_WEIGHTS["contract"] |
0.15 | backend/app/domain/quality/trust.py:33 |
app.domain.quality.trust.TRUST_WEIGHTS["lineage"] |
0.10 | backend/app/domain/quality/trust.py:34 |
app.domain.quality.trust.TRUST_WEIGHTS["documentation"] |
0.10 | backend/app/domain/quality/trust.py:35 |
app.domain.quality.trust.TRUST_WEIGHTS["ownership"] |
0.10 | backend/app/domain/quality/trust.py:36 |
app.domain.quality.trust.TRUST_WEIGHTS["sensitivity"] |
0.10 | backend/app/domain/quality/trust.py:37 |
app.domain.quality.trust.GRADE_THRESHOLDS[0][0] |
85.0 | backend/app/domain/quality/trust.py:41 |
app.domain.quality.trust.GRADE_THRESHOLDS[1][0] |
70.0 | backend/app/domain/quality/trust.py:41 |
app.domain.quality.trust.GRADE_THRESHOLDS[2][0] |
55.0 | backend/app/domain/quality/trust.py:41 |
app.domain.quality.trust.GRADE_THRESHOLDS[3][0] |
40.0 | backend/app/domain/quality/trust.py:41 |
app.domain.quality.trust.BLOCKED_CAP |
40.0 | backend/app/domain/quality/trust.py:42 |
app.domain.quality.trust.UNUSED_CAP |
60.0 | backend/app/domain/quality/trust.py:43 |
app.domain.quality.trust.SCHEMA_ACK_GRACE_DAYS |
7 | backend/app/domain/quality/trust.py:44 |
app.domain.quality.trust.CONTRACT_PROPOSED_VALUE |
0.4 | backend/app/domain/quality/trust.py:47 |
app.domain.quality.trust.LINEAGE_UPSTREAM_VALUE |
0.7 | backend/app/domain/quality/trust.py:48 |
app.domain.quality.trust.LINEAGE_COLUMN_BONUS |
0.3 | backend/app/domain/quality/trust.py:49 |
app.domain.quality.trust.DOC_README_WEIGHT |
0.4 | backend/app/domain/quality/trust.py:50 |
app.domain.quality.trust.DOC_DESCRIPTION_WEIGHT |
0.2 | backend/app/domain/quality/trust.py:51 |
app.domain.quality.trust.DOC_COLUMNS_WEIGHT |
0.4 | backend/app/domain/quality/trust.py:52 |
app.domain.quality.trust.FRESHNESS_UNKNOWN_VALUE |
0.5 | backend/app/domain/quality/trust.py:54 |
app.domain.quality.trust.FRESHNESS_ZERO_AT_RATIO |
3.0 | backend/app/domain/quality/trust.py:56 |
app.domain.quality.trust.PII_GOLD_UNMASKED_PENALTY |
0.5 | backend/app/domain/quality/trust.py:57 |
Prontidão para IA
O que mede. Se um dataset pode alimentar IA/RAG — um índice global e um
veredicto por caso de uso (backend/app/domain/quality/ai_readiness.py).
Componentes (ai_readiness.py:209-258), todos 0–100:
qualidade= score de qualidade (0 sem scan);estabilidade=max(0, 100 − 20 · issues abertos de schema_drift + schema_change + anomaly);privacidade= 100 sem PII provável; senãomax(30, 100 − 15 · colunas PII); com ≥ 2 quase-identificadores não sinalizados como PII direta, menos 10 (piso 30); num corpus documental com um issueimage_gps_piiaberto ou reconhecido (fotografias com GPS no EXIF, imagens nos buckets), menos 15 (piso 30);atualidade= dimensão «atualidade» do score (50 se ausente);volume(só casos de uso) = 100 com ≥ 100 000 linhas, 80 com ≥ 10 000, 60 com ≥ 1 000, 25 abaixo, 0 sem contagem (ai_readiness.py:123-133);rotulos(sóml, quando medido) = escala da qualidade de rótulos.
A deteção de PII é heurística: regex sobre os sample_values (email, IBAN, NIF e
telefone com pista no nome) e nomes de coluna (ai_readiness.py:92-120);
sinaliza para revisão humana, não classifica juridicamente.
Índice global = 0,40·qualidade + 0,20·estabilidade + 0,25·privacidade +
0,15·atualidade; veredicto sem_dados sem scan, pronto com ≥ 80 sem PII e sem
GPS nas imagens, com_reservas com ≥ 60, senão nao_pronto (ai_readiness.py:260-267).
Por caso de uso (ai_readiness.py:38-59, 136-206) — pesos e bloqueadores:
| Caso de uso | Pesos | Bloqueador (→ nao_pronto) |
|---|---|---|
rag |
privacidade 0,40 · qualidade 0,25 · atualidade 0,25 · estabilidade 0,10 | PII provável (seria indexada e devolvida); GPS nas imagens |
ml |
qualidade 0,35 · volume 0,25 · estabilidade 0,25 · privacidade 0,15; com rótulos medidos, todos ×0,8 e rotulos 0,20 |
GPS nas imagens; < 1 000 linhas; schema instável (drift/mudança de schema aberto) |
agente |
atualidade 0,35 · estabilidade 0,30 · qualidade 0,25 · privacidade 0,10 | atualidade < 50 |
bi |
qualidade 0,55 · atualidade 0,25 · estabilidade 0,20 | — |
Sem bloqueador: pronto ≥ 80, com_reservas ≥ 60, senão nao_pronto. Sem scan,
tudo é bloqueado.
| constante | valor | referência |
|---|---|---|
app.domain.quality.ai_readiness._WEIGHTS["qualidade"] |
0.40 | backend/app/domain/quality/ai_readiness.py:31 |
app.domain.quality.ai_readiness._WEIGHTS["estabilidade"] |
0.20 | backend/app/domain/quality/ai_readiness.py:31 |
app.domain.quality.ai_readiness._WEIGHTS["privacidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:31 |
app.domain.quality.ai_readiness._WEIGHTS["atualidade"] |
0.15 | backend/app/domain/quality/ai_readiness.py:31 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["rag"]["weights"]["privacidade"] |
0.40 | backend/app/domain/quality/ai_readiness.py:42 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["rag"]["weights"]["qualidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:42 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["rag"]["weights"]["atualidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:42 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["rag"]["weights"]["estabilidade"] |
0.10 | backend/app/domain/quality/ai_readiness.py:42 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["ml"]["weights"]["qualidade"] |
0.35 | backend/app/domain/quality/ai_readiness.py:47 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["ml"]["weights"]["volume"] |
0.25 | backend/app/domain/quality/ai_readiness.py:47 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["ml"]["weights"]["estabilidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:47 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["ml"]["weights"]["privacidade"] |
0.15 | backend/app/domain/quality/ai_readiness.py:47 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["agente"]["weights"]["atualidade"] |
0.35 | backend/app/domain/quality/ai_readiness.py:52 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["agente"]["weights"]["estabilidade"] |
0.30 | backend/app/domain/quality/ai_readiness.py:52 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["agente"]["weights"]["qualidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:52 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["agente"]["weights"]["privacidade"] |
0.10 | backend/app/domain/quality/ai_readiness.py:52 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["bi"]["weights"]["qualidade"] |
0.55 | backend/app/domain/quality/ai_readiness.py:57 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["bi"]["weights"]["atualidade"] |
0.25 | backend/app/domain/quality/ai_readiness.py:57 |
app.domain.quality.ai_readiness.USE_CASE_PROFILES["bi"]["weights"]["estabilidade"] |
0.20 | backend/app/domain/quality/ai_readiness.py:57 |
app.domain.quality.ai_readiness.MIN_ROWS_FOR_TRAINING |
1000 | backend/app/domain/quality/ai_readiness.py:61 |
app.domain.quality.ai_readiness.QUASI_IDENTIFIER_PENALTY |
10.0 | backend/app/domain/quality/ai_readiness.py:72 |
app.domain.quality.ai_readiness.PRIVACY_FLOOR |
30.0 | backend/app/domain/quality/ai_readiness.py:73 |
app.domain.quality.ai_readiness.LABEL_QUALITY_WEIGHT |
0.20 | backend/app/domain/quality/ai_readiness.py:78 |
app.domain.privacy.reidentification.MIN_QUASI_IDENTIFIERS |
2 | backend/app/domain/privacy/reidentification.py:66 |
Documentos para RAG: extração de texto e OCR
O que mede. Se um corpus documental (PDF, DOCX, TXT, MD, HTML de um bucket ou
de uma base de conhecimento) pode entrar num índice de RAG: quantos documentos se
conseguem ler, duplicados, dados pessoais, fragmentos curtos, codificação partida,
antiguidade e idioma (backend/app/domain/quality/documents.py).
Como se obtém o texto. TXT/MD/HTML/DOCX leem-se só com a biblioteca-padrão; o
PDF lê a camada de texto com o pypdf (extra documents).
O DOCX é um zip com XML, lido parte a parte em streaming (zipfile e
xml.etree.ElementTree; cartão #483, backend/app/domain/quality/docx_text.py):
- a parte principal vem da relação
officeDocumentde_rels/.rels, comword/document.xmlcomo fallback; - em cada parágrafo, os
w:tdos runs juntam-se sem separador. Uma palavra que o Word partiu em dois runs (negrito parcial, correção a meio) volta a ser uma palavra, e um email, um NIF ou um telefone partidos são detetados. As entidades XML (&,<) são descodificadas; w:tab/w:ptabdão um espaço,w:br/w:cruma quebra de linha,w:noBreakHyphenum hífen («auto-estrada») ew:softHyphennada. Parágrafos e células ficam em linhas separadas;- ficam de fora o texto apagado de uma alteração registada (
w:del,w:delText), a origem de um movimento (w:moveFrom) e os códigos de campo (w:instrText). De um campo fica o resultado; - numa caixa de texto (
mc:AlternateContent) conta só o primeiromc:Choice, senão omc:Fallback, e o texto sai uma vez; - depois do corpo vêm os cabeçalhos e rodapés referidos nas relações da parte
principal (
word/_rels/document.xml.rels; cada parte uma vez), as notas de rodapé e de fim (sem as separadoras) e os comentários; - teto de descompressão: 32 MiB por parte lida, 64 MiB no total das partes
lidas e 2 000 entradas no zip. Conta o tamanho que o zip declara e também os
bytes de facto descomprimidos, porque o declarado pode mentir. As imagens do
pacote nunca são descomprimidas. Acima do teto, o documento fica não extraível,
com a razão «Documento excede o limite de descompressão (N MiB).». Sem teto, um
DOCX de 356 KiB descomprimia 256 MiB e levava o processo a 874 MiB de memória,
num worker de 1 GiB (avaliação #433,
docs/avaliacoes/anydoc.md); - teto da estrutura do XML, porque a memória do parser cresce com a estrutura
e não só com os bytes. O XML lê-se sem montar a árvore, e há três tetos por
parte: 256 elementos abertos ao mesmo tempo, 1 MiB lido sem o parser chegar ao
fim de uma etiqueta (os atributos só existem quando ela fecha) e 10 000 nomes
distintos de elementos, atributos e prefixos. As razões são «Documento excede o
limite de aninhamento do XML (256 níveis).», «Documento excede o limite de
tamanho de uma etiqueta XML (1 MiB).» e «Documento excede o limite de nomes
distintos numa parte XML (10 000).». Sem estes tetos, um DOCX de 47 KiB com 31
MiB de
<w:p>abertos levava o processo a mais de 1,2 GiB. Em 10 DOCX reais, o máximo foi de 19 níveis, 178 nomes distintos numa parte e etiquetas de 3 KiB; - qualquer DTD é recusada em todas as partes lidas, antes de o expat ler as
declarações dela, com a razão «Falha a ler o ficheiro (ParseError).». O Word não
escreve DTD nestas partes. As entidades de uma DTD expandem-se em texto e em
valores de atributo que os tetos de bytes não contam, e o expat só recusa uma
expansão acima de 100× depois dos primeiros 8 MiB. Sem esta recusa, um DOCX de
3,7 KiB com uma entidade de 280 caracteres repetida 1 200 000 vezes num
w:tdava 336 milhões de caracteres e 991 MiB de memória. Os casos do «billion laughs» e das entidades externas acabam no mesmoParseError, e uma entidade externa nunca é lida. Como segunda barreira, o texto extraído conta também contra os tetos de 32 MiB por parte e de 64 MiB no total.
Um PDF sem camada de texto (digitalizado) passa pelo OCR local (cartão
#418, backend/app/infra/ocr/__init__.py):
- cada página é rasterizada a 300 dpi em tons de cinzento com o pypdfium2 (extra
ocr; menos numa página que passaria dos 40 Mpx, como um cartaz) e reconhecida pelo Tesseract 5 da própria imagem (pacotes Debiantesseract-ocr-por/-eng/-spa, modelos «fast»), uma página de cada vez; - uma página quase sem texto reconhecido (confiança somada abaixo de 300, ≈ 5 palavras a 60 %) leva uma segunda passagem com um filtro de mediana 5×5, e fica a melhor das duas. Numa digitalização com fundo cinzento e ruído, o Tesseract sem filtro não devolveu nenhuma palavra; com ele, 8,7 % de erro por carácter. Nas páginas limpas o filtro tirava um pouco, por isso não é a primeira passagem;
- corre no worker, assíncrono via RabbitMQ (em dev sem broker, dentro do pedido,
com os mesmos tetos). O
POST /rag-gaterecebe texto e nunca faz OCR. Nenhum serviço alojado: os documentos não saem da instalação; - teto de páginas (
DATAVAULT_DOCUMENTS_OCR_MAX_PAGES, 50) e prazo por documento (DATAVAULT_DOCUMENTS_OCR_TIMEOUT_SECONDS, 180 s). O prazo é verificado antes de cada página e o processo do Tesseract é terminado quando o esgota; - orçamento de OCR por scan (
DATAVAULT_DOCUMENTS_OCR_SCAN_BUDGET_SECONDS, 3 h): sem ele, um bucket só de digitalizados somavalimit × prazoe passava as 6 h dex-consumer-timeoutda fila de documentos (reentrega em ciclo). Esgotado, os PDFs digitalizados que faltam ficam fora do índice com a razão «o orçamento de OCR deste scan esgotou»; - sem o extra, sem o binário ou sem nenhum dos idiomas pedidos, a razão continua a ser «PDF sem camada de texto (provavelmente digitalizado) — precisa de OCR antes de entrar num índice.», como antes do OCR.
Normalização. O hash de conteúdo, os shingles, o idioma e os dados pessoais
calculam-se sobre o texto normalizado (normalize_text, a mesma função no gate
RAG):
- as ligaduras U+FB00–U+FB06 passam a letras (ff fi fl ffi ffl ſt st → ff fi fl ffi ffl st st). O pypdf deixa-as no texto: 21 no guia de facilitação do diagnóstico, um PDF de 9 páginas, e «confidencial» contava como outra palavra que «confidencial»;
- o texto passa a NFC, e um texto em NFD tem o mesmo hash que em NFC. A NFKC ficou de fora porque mudava também «²», «½» e os caracteres de largura total;
- os espaços colapsam num só.
O hash só mudou nos textos com ligaduras ou em NFD. As impressões digitais do scan refazem-se no scan seguinte. As registadas pelo gate ficam como estão, por isso um desses textos já indexado pode parecer novo ao gate uma vez.
Versão do extrator. As métricas do corpus levam extractor_version (hoje 2:
DOCX sobre XML e ligaduras dobradas). Um salto nas métricas no primeiro scan
depois de mudar o extrator explica-se por aqui: mais texto nos DOCX com rodapés
e notas, dados pessoais que antes ficavam partidos, quase-duplicados que antes
não se viam.
Como fica marcado. Cada documento leva extraction_method (texto ou ocr)
e, no OCR, ocr_engine (ex.: tesseract 5.5.0 (por+eng+spa)), ocr_confidence
(média da confiança das palavras que o Tesseract devolve, 0–100) e ocr_pages
(lidas/total). O perfil do corpus (o «Perfil da tabela» do dataset
documentos/…) ganha ocr_documents, ocr_partial_documents,
ocr_mean_confidence e ocr_method, e o corpus abre um problema «N documentos
lidos por OCR» com os caminhos: warning quando há documentos parciais (teto ou
prazo) ou com confiança abaixo de 70, info nos outros casos.
PDF misto. Um PDF em que só algumas páginas têm camada de texto (um contrato com o anexo assinado digitalizado) não passa pelo OCR, porque o OCR só corre quando nenhuma página tem texto. O índice fica com o texto das outras páginas. As páginas sem texto contam-se (o texto do pypdf dessa página, sem espaços, vem vazio) e ficam à vista (cartão #484):
- o documento leva
pages_without_text(sem texto/total, ex.:1/2); - o perfil do corpus ganha
documents_with_textless_pages; - o corpus abre um problema
warning«N documentos com páginas sem camada de texto (provavelmente digitalizadas)», com os caminhos: o índice não lê essas páginas e os dados pessoais delas não foram verificados.
Limitações.
- A confiança é a do próprio motor, não uma taxa de erro medida: serve para ordenar o que rever primeiro, não para garantir o texto.
- Um documento parcial entra no índice só com as primeiras páginas, e o problema do corpus diz quais são.
- Num PDF misto, a contagem vê a falta de texto e não a imagem: uma página em branco também conta. As páginas sem texto não descontam no score nem mudam o veredicto.
- O texto (e por isso o hash do gate RAG) depende da versão do Tesseract e dos modelos. Uma subida de versão pode fazer um documento igual parecer novo ao gate uma vez.
- Sem OCR de imagens soltas (PNG/JPEG), de tabelas nem de layout.
- O texto reconhecido nunca vai para os logs: só contagens de páginas e a confiança.
| Constante | Valor | Onde |
|---|---|---|
app.domain.quality.documents.MIN_USEFUL_CHARS |
200 | backend/app/domain/quality/documents.py:36 |
app.domain.quality.documents.NEAR_DUPLICATE_THRESHOLD |
0.85 | backend/app/domain/quality/documents.py:39 |
app.domain.quality.documents.OCR_LOW_CONFIDENCE |
70.0 | backend/app/domain/quality/documents.py:41 |
app.domain.quality.documents.EXTRACTOR_VERSION |
2 | backend/app/domain/quality/documents.py:51 |
app.domain.quality.docx_text.DOCX_MAX_PART_BYTES |
33554432 | backend/app/domain/quality/docx_text.py:41 |
app.domain.quality.docx_text.DOCX_MAX_TOTAL_BYTES |
67108864 | backend/app/domain/quality/docx_text.py:42 |
app.domain.quality.docx_text.DOCX_MAX_ENTRIES |
2000 | backend/app/domain/quality/docx_text.py:43 |
app.domain.quality.docx_text.DOCX_MAX_DEPTH |
256 | backend/app/domain/quality/docx_text.py:49 |
app.domain.quality.docx_text.DOCX_MAX_TAG_BYTES |
1048576 | backend/app/domain/quality/docx_text.py:52 |
app.domain.quality.docx_text.DOCX_MAX_NAMES |
10000 | backend/app/domain/quality/docx_text.py:54 |
app.infra.ocr.RENDER_DPI |
300 | backend/app/infra/ocr/__init__.py:34 |
app.infra.ocr.MAX_PAGE_PIXELS |
40000000 | backend/app/infra/ocr/__init__.py:37 |
app.infra.ocr.RETRY_MIN_CONFIDENCE_SUM |
300.0 | backend/app/infra/ocr/__init__.py:40 |
Imagens nos buckets: EXIF, corrompidas e quase duplicados
O que mede. Numa conexão com include_images: true, as imagens (JPEG, PNG,
WebP, GIF, TIFF, BMP) entram na mesma análise de documentos: quantas se conseguem
abrir, cópias e versões reescaladas, fotografias com GPS no EXIF, miniaturas e
antiguidade (cartão #182, backend/app/domain/quality/images.py). Só com o Pillow
(extra images). Sem ele, cada imagem fica «não analisável» com a razão do extra e
o scan termina.
Por imagem (profile_image, images.py:211-268):
- teto de píxeis (
DATAVAULT_IMAGE_MAX_PIXELS, 50 MP): largura × altura vem do cabeçalho e é conferida antes de descodificar. Acima do teto, a imagem fica «demasiado grande» e nenhum píxel é descodificado (proteção contra decompression bombs); - corrompida: o
verify()do Pillow confere a estrutura (e o CRC no PNG) e oload()descodifica a imagem. Qualquer erro, incluindo uma imagem truncada, marca-a como corrompida; - EXIF: GPS = latitude e longitude presentes (sim/não, nunca as
coordenadas); data de captura =
DateTimeOriginal, senãoDateTime; aparelho = marca e modelo. O número de série nunca é lido; - dHash de 64 bits: a imagem em cinzento reduzida a 9×8, com um bit por cada
píxel mais claro do que o vizinho à direita (
images.py:127-146); - miniatura: lado menor < 64 px; proporção extrema: lado maior ≥ 4× o menor.
No corpus (summarize_images, images.py:368-518):
- duplicados exatos pelo SHA-256 dos bytes; quase duplicados quando a
distância de Hamming entre dois dHash é ≤
DATAVAULT_IMAGE_NEAR_DUP_HAMMING(8), agrupados por união-find (AB e BC ficam no mesmo grupo). Todos os pares são comparados. Uma cópia exata entra na fase aproximada pelo representante do grupo; - antiguidade: a data de captura do EXIF quando existe, senão a data do ficheiro. Conta como antiga acima de 730 dias, como nos documentos. «Mais antiga do que o ficheiro» quando a captura é mais de 30 dias anterior à data do ficheiro;
- score =
max(0, 100 − 40·não analisáveis − 25·imagens a mais − 20·com GPS − 10·miniaturas − 5·antigas), cada termo como fração do total de imagens; - issues
document:image_corrupt(criticalacima de 10 % das imagens, senãowarning),image_near_duplicateseimage_gps_pii(warning).
Com documentos e imagens no mesmo corpus, o score é a média dos dois, pesada pelo
número de ficheiros. O veredicto só é «pronto» com score ≥ 80, os documentos
«prontos» e nenhuma imagem com GPS ou por analisar (merge_image_summary,
images.py:521-556). Um issue image_gps_pii aberto ou reconhecido conta também na
prontidão para IA, no /datasets/{id}/ai-readiness e na regra
min_ai_readiness da política de dados. O scan mantém o issue reconhecido enquanto
há fotografias com GPS, por isso só resolvê-lo ou ignorá-lo tira o bloqueador.
Limitações.
- O dHash apanha reescalar e recomprimir. Numa sonda com 20 imagens sintéticas, a distância ficou ≤ 3 com metade da resolução e com JPEG de qualidade 30. Com um espelho, uma rotação de 90° ou um recorte de 20 % das margens, ficou sempre acima de 8, por isso estas variantes não contam como quase duplicados.
- Com
include_images, imagens e documentos partilham olimitdo pedido. No GCS e no S3, o corte segue a ordem alfabética dos caminhos. - Os pixels nunca são guardados nem enviados a um LLM. Da imagem só ficam as métricas.
| Constante | Valor | Onde |
|---|---|---|
app.domain.quality.images.MIN_USEFUL_PIXELS |
64 | backend/app/domain/quality/images.py:40 |
app.domain.quality.images.ASPECT_EXTREME_RATIO |
4.0 | backend/app/domain/quality/images.py:41 |
app.domain.quality.images.EXIF_OLDER_THAN_FILE_DAYS |
30 | backend/app/domain/quality/images.py:44 |
app.domain.quality.documents.STALE_AFTER_DAYS |
730 | backend/app/domain/quality/documents.py:38 |
app.config.settings.Settings.model_fields["image_near_dup_hamming"].default |
8 | backend/app/config/settings.py:822 |
app.config.settings.Settings.model_fields["image_max_pixels"].default |
50000000 | backend/app/config/settings.py:831 |
Recalibração de limiares
O que mede. Se o threshold de uma regra está desalinhado da realidade, a partir
do histórico de resultados dessa regra (backend/app/domain/rules/tuning.py).
Só propõe; nada muda sem o utilizador aceitar.
Diagnóstico (tuning.py:214-234), sobre os pontos avaliados com o threshold
atual (resultados anteriores a uma mudança de parâmetros ficam de fora) e com
pelo menos 6 pontos válidos:
always_failing— ≥ 90 % de falhas e série estável (coeficiente de variaçãopstdev/|média| < 0,25): o threshold está errado, não é um incidente;flapping— ≥ 40 % de transições passa↔falha: o threshold está em cima do ruído;too_loose— zero falhas e o threshold desproporcionado face ao observado (tuning.py:370-398): em regras de máximothreshold > 3 · p95; em regras de mínimop5 > 3 · threshold; emrangea folga de cada limite definido tem de exceder 3× a amplitude p5–p95;uniquenunca é «folgada» (threshold fixo em zero). A regra nunca apanharia uma degradação;healthy— nada a fazer (inclui falhas numa série instável: aí é a qualidade dos dados que varia e a regra está a funcionar).
Proposta (tuning.py:406-421), por quantis com interpolação linear
(tuning.py:359-367): percentagens (not_null, max_malformed_pct) →
ceil(p95 · 1,25), ou a convenção «quase zero» do gerador de regras abaixo de
0,5 %; freshness → p95 · 2 arredondado ao degrau seguinte de 24, 48, 72, 168,
336 ou 720 h; row_count_min → floor(p5 · 0,8); range → [p5 − margem, p95 + margem] com margem de 10 % da amplitude. Guarda de severidade: uma
regra critical nunca afrouxa além de 2× o valor atual (metade, em mínimos).
| constante | valor | referência |
|---|---|---|
app.domain.rules.tuning.DEFAULT_MIN_POINTS |
6 | backend/app/domain/rules/tuning.py:59 |
app.domain.rules.tuning.FLAPPING_TRANSITION_RATE |
0.4 | backend/app/domain/rules/tuning.py:60 |
app.domain.rules.tuning.ALWAYS_FAILING_RATE |
0.9 | backend/app/domain/rules/tuning.py:61 |
app.domain.rules.tuning.STABLE_CV |
0.25 | backend/app/domain/rules/tuning.py:62 |
app.domain.rules.tuning.TOO_LOOSE_FACTOR |
3.0 | backend/app/domain/rules/tuning.py:63 |
app.domain.rules.tuning.PCT_MARGIN |
1.25 | backend/app/domain/rules/tuning.py:65 |
app.domain.rules.tuning.ROW_COUNT_FLOOR |
0.8 | backend/app/domain/rules/tuning.py:67 |
app.domain.rules.tuning.CRITICAL_LOOSEN_FACTOR |
2.0 | backend/app/domain/rules/tuning.py:69 |
app.domain.rules.generation.NEAR_ZERO_NULL_PCT |
1.0 | backend/app/domain/rules/generation.py:86 |
app.domain.rules.generation.RANGE_MARGIN |
0.1 | backend/app/domain/rules/generation.py:94 |
app.domain.rules.generation.FRESHNESS_FACTOR |
2.0 | backend/app/domain/rules/generation.py:95 |
app.domain.rules.generation.FRESHNESS_STEPS_HOURS |
24, 48, 72, 168, 336, 720 | backend/app/domain/rules/generation.py:97 |
Reconciliação e data-diff
Reconciliação de contagens (backend/app/domain/quality/reconcile.py:53-89).
Compara o row_count dos perfis mais recentes de origem e destino — sem query
nova à fonte, por isso os dois lados podem ter idades diferentes (mostradas).
delta_pct = (destino − origem) · 100 / origem
ok se |delta_pct| ≤ tolerance_pct (0 por omissão)
warn se |delta_pct| ≤ fail_pct (2 × tolerance_pct por omissão)
fail acima disso, ou origem = 0 com destino > 0 (percentagem indefinida)
Data-diff por chave (backend/app/domain/quality/data_diff.py), para CI:
compara base e candidato linha a linha pela chave declarada, lendo até
diff_max_rows por lado e ≤ diff_max_columns colunas.
- Amostragem declarada (
data_diff.py:27-29, 302-310):exactquando ambos os lados cabem no limite;sample_moduluscom chave inteira única — lê as linhas comchave % m = 0,m = ceil(máximo / limite)— determinística e reproduzível, mas sistemática, não aleatória (uma chave com padrão de módulo enviesa);key_rangecaso contrário — as primeiras N linhas por ordem da chave. - Janela honesta: quando um lado foi truncado, só se comparam as chaves ≤ à menor última chave lida por ambos — uma linha fora do limite do outro lado nunca conta como «só-esquerda».
- Checksum:
SUM(HASH)por coluna sobre a tabela inteira quando ambos os lados o suportam com a mesma família de hash (checksum_dialectigual — os hashes por dialeto estão em Distintos aproximados, incluindo BigQuery, Snowflake, Databricks e SQL Server desde o cartão #317;data_diff.py:175-180; entre motores com funções de hash diferentes cai para o checksum da amostra, e o mesmo numa coluna sem checksum num dos lados —data_diff.py:187-199);diverged_outside_samplesinaliza valores diferentes fora da amostra quando as linhas coincidem; senão checksum sobre a amostra. - Veredito (
data_diff.py:233-245):failsemax(% linhas diferentes, % delta de contagem) > fail_pct(5 %);warnse> warn_pct(1 %), coluna acima decolumn_threshold_pct(0 = qualquer diferença), schema diferente ou chave não única; senãopass. O relatório trazsampling.estimated=truesempre que o método não éexactou houve janela.
| constante | valor | referência |
|---|---|---|
app.domain.quality.data_diff.DiffThresholds.warn_pct |
1.0 | backend/app/domain/quality/data_diff.py:36 |
app.domain.quality.data_diff.DiffThresholds.fail_pct |
5.0 | backend/app/domain/quality/data_diff.py:37 |
app.domain.quality.data_diff.DiffThresholds.column_threshold_pct |
0.0 | backend/app/domain/quality/data_diff.py:40 |
app.domain.quality.data_diff.METHOD_MODULUS |
sample_modulus | backend/app/domain/quality/data_diff.py:28 |
app.domain.quality.data_diff.METHOD_KEY_RANGE |
key_range | backend/app/domain/quality/data_diff.py:29 |
app.config.settings.Settings.model_fields["diff_max_columns"].default |
50 | backend/app/config/settings.py:1076 |
Impacto financeiro estimado
O que mede. Um custo mensal em euros da má qualidade por dataset, mostrado nos
issues e nos dashboards de gestão (backend/app/domain/quality/impact.py). É uma
heurística declarada, com todos os pressupostos expostos em assumptions:
armazenamento_desperdiçado = linhas_duplicadas · bytes_por_linha / 1e9 · custo_por_GB_mês
bytes_por_linha = tamanho_da_tabela / row_count, ou 512 B quando a fonte não reporta tamanho
horas_de_retrabalho = 0,75 h · issues_abertos + 0,05 h · linhas_duplicadas / 1000
custo_de_retrabalho = horas_de_retrabalho · custo_hora_engenheiro
custo_por_GB_mês e custo_hora_engenheiro vêm da configuração
(backend/app/config/settings.py:1070, 1082). As linhas duplicadas são as da
amostra, por isso o número é sempre estimado e tende a
subestimar em tabelas grandes.
| constante | valor | referência |
|---|---|---|
app.domain.quality.impact.DEFAULT_BYTES_PER_ROW |
512.0 | backend/app/domain/quality/impact.py:14 |
app.domain.quality.impact.HOURS_PER_OPEN_ISSUE |
0.75 | backend/app/domain/quality/impact.py:16 |
app.domain.quality.impact.HOURS_PER_1000_DUPLICATE_ROWS |
0.05 | backend/app/domain/quality/impact.py:17 |
app.config.settings.Settings.model_fields["cost_per_gb_month_eur"].default |
0.023 | backend/app/config/settings.py:1295 |
app.config.settings.Settings.model_fields["engineer_hourly_cost_eur"].default |
45.0 | backend/app/config/settings.py:1307 |
Exato, estimado ou amostrado
Resumo por número mostrado na interface. «Exato» refere-se ao que a fonte devolve por agregação; num perfil incremental os agregados são base + janela.
Desde o cartão #350, os números amostrados levam na API os mesmos campos —
estimated, sample_size e o intervalo de confiança a 95 % (ci95, ou
<campo>_ci95 quando o objeto tem vários números) — descritos em
api/estimativas.md. Os intervalos vêm de
backend/app/domain/quality/stats.py: Wilson para proporções (não sai de [0, 1] nem
colapsa com 0 sucessos; Brown, Cai & DasGupta, 2001), com a correção de população
finita quando se conhece o número de linhas da tabela, e a transformação z de Fisher
para as correlações de Pearson. Na interface, o componente «≈» (EstimateBadge) diz o tamanho da
amostra e o intervalo.
| Número na interface | Onde | Natureza | Notas |
|---|---|---|---|
| Linhas, nulos, distintos, mín/máx, média, desvio | perfil da coluna | exato | distintos saltados acima de 25 M de linhas; estimados (HLL ±1,6 %) em perfis incrementais. Ficheiros GCS/S3 acima de 100 000 linhas: métricas de coluna amostradas (from_sample), exceto as do footer Parquet (exact_metrics); linhas exatas, ou row_count_estimated acima do teto de contagem |
| Score de qualidade | dataset, catálogo, dashboards | exato, exceto unicidade | unicidade e validade de regras row-based são amostradas; o número do score não leva marca, a dimensão «unicidade» leva «≈» |
| Completude, validade, atualidade, consistência | dimensões do score | exato | validade amostrada nas regras pattern/required_fields (5 000 linhas) e valid_identifier/in_reference_set (20 sample_values); o issue diz a amostra, a dimensão não |
| Unicidade | dimensão do score | amostrado | duplicados nas 5 000 linhas da amostra ÷ essas linhas (cartão #294), com o método do dialeto (cartão #317); «≈» e «Estimado sobre amostra … de N linhas» no detalhe do dataset; IC 95 % de Wilson com correção de população finita (ci95, cartão #350) |
| Drift (nulos, linhas, schema) | issues, consistência | exato | face ao último scan concluído; «Contagem estimada» na descrição quando um row_count de GCS/S3 foi extrapolado |
Violações das regras expression/custom_sql |
regras, issues, consistência | exato | contagem na fonte sobre a tabela inteira, até 1 000 000 (count_capped); fontes sem SQL: «não avaliável» |
Chaves órfãs da regra referential |
regras, issues, integridade, consistência | exato / indeterminado | mesma ligação: LEFT JOIN contado na fonte, até 1 000 000; a % usa as linhas com chave do perfil (estimada num perfil amostrado). Entre ligações: anti-join em memória com até 200 000 linhas lidas da referência (linhas, não chaves distintas: numa chave primária são o mesmo); acima disso, ou com o teto de linhas da ligação a cortar a referência, «não avaliada» (não conta). Chaves comparadas como texto (7, 7.0 e "7" são a mesma; "007" não) |
Linhas duplicadas da regra unique |
regras, issues, CI gate | exato / indeterminado | GROUP BY na fonte sobre a tabela inteira; ficheiros cortados pelo teto só provam duplicados; sem prova, «indeterminada» (não conta) |
| Anomalia (z-score, esperado, faixa normal) | issues, explicação | exato sobre o histórico | baseline global (12) ou semanal (5–8, UTC); limiar por n |
Anomalia (p_value, confiança) |
issues, explicação | estimado | modelo nulo calibrado com ruído gaussiano (harness do #319) |
| Drill-down por segmento (share) | explicação da anomalia | exato | mediana por segmento de ≤ 12 snapshots; só com pushdown |
| Monitores automáticos (cadência, volume) | painel de monitores | exato face aos metadados | «indisponível» quando a fonte não expõe o metadado |
| Anomalia de custo | problemas, FinOps | exato sobre a telemetria | conversão USD→EUR fixa |
Clusters de duplicados e confidence |
duplicados | amostrado | ≤ 5 000 linhas, ≤ 4 colunas de texto; «% da amostra», o intervalo («entre … e … (IC 95%)», duplicate_pct_ci95; com as primeiras linhas, «só se as primeiras linhas representarem a tabela») e o método da amostra no issue quando não é a tabela inteira; confidence é semelhança média, não probabilidade; a interface mostra a banda calibrada no benchmark sintético |
| Correspondência entre datasets (score, cobertura) | duplicados → entre datasets | exato sobre as linhas lidas / estimado | ≤ 50 000 linhas por lado; com um lado cortado, estimated=true e IC 95 % de Wilson da cobertura desse lado (coverage_a_ci95/coverage_b_ci95) |
| Quantis | distribuições | exato / estimado / amostrado | badge por método: percentile_cont «Exato»; APPROX_* «Aproximado» (estimated=true); NTILE(20) «resolução de 5%» no badge mas estimated=false no perfil; sem pushdown, «Estimado por amostra» |
| Histogramas | distribuições | exato / amostrado | largura igual; contagens exatas no pushdown |
| Correlações (r) | distribuições | exato / amostrado | só com n ≥ 30 e IC 95 % de Fisher sem o zero, depois ` |
| Co-nulidade (Jaccard) | distribuições | exato / amostrado | só ≥ 0,5 |
Tipo semântico e valid_pct |
colunas | amostrado | até 20 valores distintos; a interface diz «N/M valores da amostra» (sample_size) em vez da percentagem |
| PII provável | prontidão para IA, sensibilidade | heurístico | regex sobre 20 distintos + nome da coluna; o mapa de PII diz «N/M valores da amostra» quando a deteção veio do tipo semântico |
| % rótulos suspeitos | qualidade de rótulos | estimado | modelo barato + validação cruzada sobre ≤ 50 000 linhas; com amostra, IC 95 % de Wilson (pct_suspect_ci95) e a dimensão «rótulos» da prontidão para IA usa o limite inferior; lida a tabela inteira, sem intervalo |
| Exatidão estimada e intervalo | auditoria de exatidão, dimensão Exatidão, trust | amostrado | registos revistos por pessoas, escolhidos ao acaso entre ≤ 5 000 linhas lidas; Wilson sobre o n efetivo de Kish. Quando a fonte só devolve as primeiras linhas, vale só para essas |
| Trust score e grade | dataset, API de confiança | exato | determinístico sobre sinais já medidos |
| Prontidão para IA (índice, casos de uso) | dataset | exato sobre sinais medidos | herda a natureza de cada sinal |
| Proposta de recalibração | sugestões | exato sobre o histórico | quantis com interpolação linear |
| Reconciliação de contagens | reconciliação | exato | perfis podem ter idades diferentes |
| Data-diff | CI | exato / estimado | estimated=true fora de exact ou com janela |
| Impacto financeiro (€/mês) | issues, gestão | estimado | heurística declarada; duplicados amostrados |
Texto de PDFs digitalizados e ocr_confidence |
documentos | estimado | OCR local (Tesseract); confiança do motor, não taxa de erro; teto de páginas e de tempo por documento |
Referências
- Iglewicz, B. & Hoaglin, D. C. (1993). How to Detect and Handle Outliers. ASQC Quality Press — z-score modificado, constantes 0,6745 e 3,5.
- Benjamini, Y. & Hochberg, Y. (1995). Controlling the False Discovery Rate. JRSS B 57(1) — controlo de multiplicidade das anomalias por dataset.
- Benjamini, Y. & Yekutieli, D. (2001). The control of the false discovery rate in multiple testing under dependency. Annals of Statistics 29(4).
- Press, W. H. et al. Numerical Recipes — função beta incompleta regularizada (fração contínua), usada na distribuição t do p-valor das anomalias.
- Flajolet, P., Fusy, É., Gandouet, O. & Meunier, F. (2007). HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm. AofA 2007.
- Brown, L. D., Cai, T. T. & DasGupta, A. (2001). Interval Estimation for a Binomial Proportion. Statistical Science 16(2) — intervalo de Wilson.
- Fisher, R. A. (1915). Frequency distribution of the values of the correlation coefficient in samples from an indefinitely large population. Biometrika 10(4) — transformação z.
- Northcutt, C., Jiang, L. & Chuang, I. (2021). Confident Learning: Estimating Uncertainty in Dataset Labels. JAIR 70.
- Jaccard, P. (1901). Étude comparative de la distribution florale… — índice de Jaccard (co-nulidade).
- rapidfuzz —
fuzz.ratio(semelhança Indel normalizada) efuzz.token_sort_ratio. - Open Data Contract Standard (ODCS) v3.1 — tipos lógicos usados na classificação de mudanças breaking.
- Contexto de produto: PRODUTO.md; contratos entre módulos: ARQUITETURA.md §3.2; capacidades: CAPACIDADES.md.