Saltar para o conteúdo
BeryX
Voltar às respostas para RFP

Documentação do BeryX

Métodos — como o BeryX calcula cada número

Este documento consolida a metodologia estatística do BeryX: 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.sampled e 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 regras pattern, required_fields, valid_identifier e in_reference_set dizem 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 em backend/app/config/settings.py e são indicados como tal. Nenhum método usa um LLM: tudo aqui é determinístico.

Amostragem

O BeryX 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 um LIMIT de rede de 2 × limit; o excesso corta-se em Python pelas linhas de menor hash (rank_rows, backend/app/infra/connectors/base.py:604-617) — o LIMIT sozinho cortava sempre o fim do último bloco lido. Na amostra por hash, k/10⁶ ≈ 1,5 × limit ÷ linhas e a ordenação pelo próprio hash guarda os n menores: uma amostra uniforme de exatamente n linhas. O fraction gravado é linhas devolvidas ÷ linhas da tabela (sem fraction na janela do INCR, onde só a janela foi contada).
  • Semente. stable_seed(dataset.id) (blake2b, em [0, 2³¹), o intervalo do SEED do 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 com resample (API POST /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 a head. O mesmo quando os blocos amostrados vêm vazios (tabela pequena no BigQuery). As tabelas acima de sample_full_scan_max_rows não levam leituras da tabela inteira (BERNOULLI, hash, ORDER BY): ficam system ou head sem ordem. Um erro que nenhum recurso resolve (o teto de bytes por query do BigQuery) não cai para head: sobe com a mensagem do teto.
  • Janelas. O TABLESAMPLE corre antes do WHERE. 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 do TABLESAMPLE. 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 o impact_json/details_json levam o mesmo sample.
  • Amostra ou tabela inteira. Decide-se pelo covers_table do 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, freshness passada 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 da freshness, scoring.py:42); geram issues. As expression/custom_sql/referential entram 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_count da 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 com TABLESAMPLE/SAMPLE ou 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 reescreve Issue.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 o scan_id dos 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 em unexplained_points com a nota drift_not_recorded, em vez de se inventar uma causa. A API pública não expõe essa chave (dimensions_meta continua só com estimativas).
  • Desde o cartão #351, dimensions_meta_json["drift"] só leva o drift estrutural, e dimensions_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 nota consistency_model_changed diz que o cálculo mudou.
  • Os resultados indeterminados (cartão #291) ficam fora, como no score.
  • observed/threshold estã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 de kind + 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 info falhada conta tanto como uma critical. O score diz «quantas verificações passaram», não «quão grave é».
  • As diferenças face ao composto são de propósito: required_fields conta na completude (no composto, na validade) e as regras not_null, unique e row_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 regra accuracy avaliada (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 diz count_capped e 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 é da not_null. null_as_violation conta-as também.

  • soma(a, b) trata NULL como 0, como o SUM do 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:

    • nif e niss (PT);
    • cpf e cnpj (BR). No CNPJ alfanumérico, cada carácter vale ASCII − 48, lido pela posição numa tabela literal: o SQLite não tem ASCII() e a guarda do SQL não a deixa passar;
    • dni e nie (ES), com a letra tirada por SUBSTR da tabela mod 23;
    • nhs (GB), ahv e uid (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 na valid_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). No custom_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ício para pares de datas pelo nome cujos mínimos e máximos do perfil são coerentes, e total = soma(parcelas) − descontos quando, 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):

  1. Contagem na fonte (details.source = "pushdown"). Fontes SQL: a tabela inteira, na sessão só-de-leitura das regras SQL, com sql_rule_timeout_s como timeout do statement. CSV, GCS e S3: as mesmas linhas que o perfil leu.
  2. Linhas lidas ("rows"), só quando são a tabela inteira (a leitura acabou antes do teto de 5 000).
  3. Distintos do perfil ("profile"), só com uma coluna e COUNT(DISTINCT) exato.
  4. 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 (beryx-profiler) calcula COUNT(*) − COUNT(DISTINCT chave) sobre as mesmas linhas, que dá o mesmo número (casos de paridade em backend/tests/golden/spark/parity_cases.json).
  • Indeterminada = sem prova num sentido nem noutro: o GROUP BY excedeu 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 de profile_distinct_max_rows) ou estimados por HLL. Grava-se com passed=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 regista rule_indeterminate com 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 unique em 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_distributions recebe os limites do scan anterior.
  • Perfis incrementais e profile_depth=basic nã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 do APPROX_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:

  1. 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);
  2. passa as guardas de relevância (anomalies.py:295-299): row_count e distinct_count só contam se |x − mediana| / max(|mediana|, 1) ≥ 10 %; null_pct só se o desvio for ≥ 1 ponto percentual;
  3. |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;
  4. 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: high com p ≤ 0,0001, medium com p ≤ 0,001 e low acima 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_count e 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_at do 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 do last_modified da fonte ou, sem ele, das variações do contador de escritas entre snapshots (resolução = cadência de scan). cadência = mediana dos intervalos e o alerta dispara quando a idade excede max(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) e last_modified (máximo) das partições em INFORMATION_SCHEMA.PARTITIONS; se a vista não for legível, de __TABLES__ (backend/app/infra/connectors/bigquery.py:757-796).
  • Snowflake: ROW_COUNT e LAST_ALTERED de INFORMATION_SCHEMA.TABLES (backend/app/infra/connectors/snowflake.py:95-112).
  • Databricks: só o lastModified de DESCRIBE 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, ou reltuples numa tabela nunca analisada) e contador de escritas (n_tup_ins + n_tup_upd + n_tup_del) de pg_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) e UPDATE_TIME de information_schema.TABLES (backend/app/infra/connectors/sql.py:1381-1391).
  • SQL Server: contagem de sys.dm_db_partition_stats, ou de sys.partitions com um login só db_datareader (backend/app/infra/connectors/mssql.py:349-370). O modify_date só 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, para vendor não passar por end) 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 regra freshness com column nessa coluna falha com erro em vez de mostrar «idade 0».
  • Regra. Com column, a regra mede essa coluna (o max_value dela no perfil); sem column, 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 do source_timezone da 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 guarda latest_timestamp_tz (aware ou assumed:<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):

  1. as colunas normalizadas são concatenadas por espaço;
  2. 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áximo max(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);
  3. scorer = max(token_sort_ratio, ratio) do rapidfuzz — o ratio é a semelhança Indel normalizada (0–100), o token_sort_ratio aplica-a às palavras ordenadas (apanha trocas de ordem); o par liga-se quando a pontuação é ≥ threshold · 100, com threshold = 0.9 desde o #352, antes 0,85 (FUZZY_THRESHOLD, duplicates.py:31, 356-359); pares exatamente iguais são saltados (já são exatos);
  4. os pares ligados formam clusters por union-find (transitividade: AB e BC juntam A, B e C mesmo que A e C não sejam parecidos);
  5. 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_ratio 100 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);
  6. banda (cluster_band, duplicates.py:258-284): a API devolve confidence_band (high ou low; exact só com confidence = 1 e linhas iguais na amostra, o que trata os clusters fuzzy antigos gravados com 1,0) e reference_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):

  1. normalização por chave: email (lower/strip), nif (exatamente 9 dígitos, senão vazio; um prefixo de país que não é PT, como GB ou CHE, ou o EIN NN-NNNNNNN também dão vazio), name (sem acentos nem pontuação), postal_code/digits (só dígitos), text (lower sem acentos); auto escolhe pelo nome da coluna. phone_pt dá E.164 no plano de numeração do país dos dados de cada lado (phones.normalize_phone, com o phonenumbers, cartão #516): fora de PT, «912 345 678» é +34912345678 num dataset espanhol, «015 11 91234-5678» é +5511912345678 num brasileiro e «1-212-555-0123» é +12125550123 nos 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 n sem indicativo recebe o c do único +c n da chave;
    • com vários +c n, recebe o indicativo dominante (dominant_calling_code: o que mais vezes liga um n a um +c n), quando é um deles;
    • sem ligação, o +351 aplica-se aos 9 dígitos;
  2. candidatos: chaves exact/normalized por índice invertido (join por hash); valores que se repetem mais de 50 vezes num lado não geram candidatos (lixo como «n/a»); chaves fuzzy por token_sort_ratio dentro 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;
  3. pontuação = média ponderada das chaves presentes nos dois lados (exact/normalized valem 1 ou 0; fuzzy vale a razão 0–1 se ≥ threshold da chave, senão 0; matching.py:609-619); chaves vazias num lado não entram no denominador;
  4. decisão: par quando score ≥ min_score (0,85); uma linha é ambígua quando tem ≥ 2 candidatos a menos de ambiguity_epsilon (0,02) do melhor; pairs leva 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 e min_score 0,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_epsilon 0,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 do rapidfuzz sobre o valor normalizado, que dá um bónus ao prefixo comum;
  • token: token_sort_ratio do rapidfuzz, 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áximo window_days dias, 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 BeryX, 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 exact e normalized, e o normalized fica aproximado por LOWER(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 que consolidate escolhe para esse par, com os mesmos empates: most_recent e most_complete têm expressão própria nos dois, e com dois valores most_frequent empata e fica a prioridade das fontes. Num cluster com mais de dois registos, a escolha entre todos só está na app. O export só trata NULL como 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 CAST para STRING, e um BYTES com UTF-8 inválido falha. Esses tipos, e os desconhecidos, vão por TO_JSON_STRING.
  • SQL Server e Fabric. O CAST usa o estilo 0, por isso o texto sai por CONVERT com 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):

  1. amostra: até label_quality_max_rows linhas 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 por LIMIT). A estratificação em memória por classe (stratified_sample, label_quality.py:137-152) só reduz quando recebe mais linhas do que max_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);
  2. 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;
  3. 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;
  4. modelo hgb (HistGradientBoostingClassifier, 100 iterações) ou logreg, com validação cruzada estratificada de 5 folds (baralhada, semente 0) para obter probabilidades out-of-fold por linha;
  5. cleanlab.filter.find_label_issues ordena 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.

  1. Leitura: até POOL_MAX_ROWS linhas (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).
  2. 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 teto MAX_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.
  3. Estratos (build_strata, allocate, accuracy_audit.py:133-207): os valores de uma coluna escolhida dividem as linhas lidas. Acima de MAX_STRATA valores, a cauda junta-se em «(outros)». A repartição é proporcional (maior resto), com pelo menos MIN_PER_STRATUM registos 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 o row_count conta 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ão max(30, 100 − 15 · colunas PII); com ≥ 2 quase-identificadores não sinalizados como PII direta, menos 10 (piso 30); num corpus documental com um issue image_gps_pii aberto 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 officeDocument de _rels/.rels, com word/document.xml como fallback;
  • em cada parágrafo, os w:t dos 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 (&amp;, &lt;) são descodificadas;
  • w:tab/w:ptab dão um espaço, w:br/w:cr uma quebra de linha, w:noBreakHyphen um hífen («auto-estrada») e w:softHyphen nada. 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 primeiro mc:Choice, senão o mc: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:t dava 336 milhões de caracteres e 991 MiB de memória. Os casos do «billion laughs» e das entidades externas acabam no mesmo ParseError, 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 Debian tesseract-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-gate recebe 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 somava limit × prazo e passava as 6 h de x-consumer-timeout da 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.

Memória. A normalização, o idioma, os shingles e o hash percorrem o texto em blocos de 262 144 caracteres (_BLOCK_CHARS, cartão #593), e nenhuma lista guarda uma entrada por palavra. No pico ficam duas cópias do texto e um bloco. Num TXT de 50 MB, o pico do tracemalloc desceu de 993 MiB para 100 MiB; num DOCX com duas partes de 31 MiB, de 1 235 MiB para 120 MiB. Os blocos acabam onde a NFC, as minúsculas e as palavras de cada bloco são as do texto inteiro, por isso o hash, os shingles, o idioma e as contagens não mudaram.

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:39
app.domain.quality.documents.NEAR_DUPLICATE_THRESHOLD 0.85 backend/app/domain/quality/documents.py:42
app.domain.quality.documents.OCR_LOW_CONFIDENCE 70.0 backend/app/domain/quality/documents.py:44
app.domain.quality.documents.EXTRACTOR_VERSION 2 backend/app/domain/quality/documents.py:64
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 o load() 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ão DateTime; 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 (critical acima de 10 % das imagens, senão warning), image_near_duplicates e image_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 o limit do 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:41
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ção pstdev/|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áximo threshold > 3 · p95; em regras de mínimo p5 > 3 · threshold; em range a folga de cada limite definido tem de exceder 3× a amplitude p5–p95; unique nunca é «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): exact quando ambos os lados cabem no limite; sample_modulus com chave inteira única — lê as linhas com chave % 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_range caso 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_dialect igual — 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_sample sinaliza valores diferentes fora da amostra quando as linhas coincidem; senão checksum sobre a amostra.
  • Veredito (data_diff.py:233-245): fail se max(% linhas diferentes, % delta de contagem) > fail_pct (5 %); warn se > warn_pct (1 %), coluna acima de column_threshold_pct (0 = qualquer diferença), schema diferente ou chave não única; senão pass. O relatório traz sampling.estimated=true sempre que o método não é exact ou 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) e fuzz.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.