Metadata-Version: 2.4
Name: logus-lgpd
Version: 1.0.4
Summary: Privacy-by-Design para dados tabulares — LGPD compliance em Python.
Author-email: Leonardo Borges <leonardoborges6947@gmail.com>
License: GNU AGPLv3
Project-URL: Homepage, https://github.com/Leo-bsb/logus
Project-URL: Source, https://github.com/Leo-bsb/logus
Keywords: lgpd,privacy,pii,masking,anonymization,polars,pandas
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: polars>=1.0.0
Requires-Dist: pyarrow>=14.0.0
Requires-Dist: cryptography>=41.0.0
Requires-Dist: numpy>=1.24.0
Requires-Dist: pandas>=2.0.0
Provides-Extra: excel
Requires-Dist: openpyxl>=3.1.0; extra == "excel"
Provides-Extra: synthetic
Requires-Dist: ctgan>=0.9.0; extra == "synthetic"
Requires-Dist: faker>=18.0.0; extra == "synthetic"
Requires-Dist: scikit-learn>=1.3.0; extra == "synthetic"
Provides-Extra: sql
Requires-Dist: duckdb>=0.10.0; extra == "sql"
Requires-Dist: sqlalchemy>=2.0.0; extra == "sql"
Provides-Extra: full
Requires-Dist: openpyxl>=3.1.0; extra == "full"
Requires-Dist: duckdb>=0.10.0; extra == "full"
Requires-Dist: sqlalchemy>=2.0.0; extra == "full"
Requires-Dist: ctgan>=0.9.0; extra == "full"
Requires-Dist: faker>=18.0.0; extra == "full"
Requires-Dist: scikit-learn>=1.3.0; extra == "full"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.4.0; extra == "dev"
Requires-Dist: mypy>=1.8.0; extra == "dev"
Dynamic: license-file

# logus v1.0.4

**Um vocabulário. Engine Polars. Privacidade LGPD.**

logus é uma biblioteca Python para análise de dados com privacidade embutida.
Em vez de alternar entre `pd.funcao`, `pl.funcao` e funções de anonimização separadas,
você usa `lg.*` para tudo — internamente Polars quando disponível, pandas como fallback.

```python
import logus as lg

# Lê qualquer formato — auto-detecta encoding e tipo de arquivo
df = lg.read("clientes.csv")            # pl.DataFrame
df = lg.read("clientes.xlsx")           # pl.DataFrame
df = lg.read("clientes.parquet")        # pl.DataFrame

# Manipula com linguagem natural / SQL
df = lg.where(df, uf="SP", tipo_pessoa="PF")
df = lg.sort(df, "renda_mensal", desc=True)
df = lg.add_column(df, imposto=lg.col("renda_mensal") * 0.27)

# Detecta e mascara PII automaticamente (LGPD)
reports = lg.scan(df)                   # descobre CPF, e-mail, telefone...
df_safe = lg.mask(df, salt=SALT)        # mascara com HMAC-SHA256

# Salva cifrado com AES-256-GCM
lg.store(df_safe, "clientes.lgs", key=KEY)
df_back = lg.read("clientes.lgs", key=KEY)
```

---

## Instalação

```bash
pip install logus-lgpd

# Com Polars (recomendado — 2-20× mais rápido)
pip install "logus-lgpd[polars]"

# Com SQL via DuckDB
pip install "logus-lgpd[sql]"

# Tudo
pip install "logus-lgpd[full]"
```

**Obrigatórias:** `pandas` `pyarrow` `cryptography` `numpy`  
**Opcionais:** `polars` (performance) `duckdb` (lg.sql) `sqlalchemy` (banco)

---

## Índice

1. [Conceitos fundamentais](#1-conceitos-fundamentais)
2. [Leitura de arquivos — `lg.read()`](#2-leitura-de-arquivos--lgread)
3. [Filtragem — `lg.where()`](#3-filtragem--lgwhere)
4. [Seleção e transformação de colunas](#4-seleção-e-transformação-de-colunas)
5. [Ordenação — `lg.sort()`](#5-ordenação--lgsort)
6. [Agrupamento — `lg.groupby()`](#6-agrupamento--lggroupby)
7. [Expressões — `lg.col()`](#7-expressões--lgcol)
8. [CASE WHEN — `lg.when()`](#8-case-when--lgwhen)
9. [Pipeline fluente — `lg.pipe()`](#9-pipeline-fluente--lgpipe)
10. [SQL direto — `lg.sql()`](#10-sql-direto--lgsql)
11. [Privacidade — `lg.scan()` e `lg.mask()`](#11-privacidade--lgscan-e-lgmask)
12. [Formato `.lgs` — arquivo seguro](#12-formato-lgs--arquivo-seguro)
13. [Multi-frame e `lg.open()`](#13-multi-frame-e-lgopen)
14. [JOIN seguro — `lg.join()`](#14-join-seguro--lgjoin)
15. [Diagnóstico — `lg.profile()` e `lg.diff()`](#15-diagnóstico--lgprofile-e-lgdiff)
16. [Verificação de privacidade — `lg.check`](#16-verificação-de-privacidade--lgcheck)
17. [Streaming — `lg.stream()`](#17-streaming--lgstream)
18. [Banco de dados — `lg.read_db()` e `link`](#18-banco-de-dados--lgread_db-e-link)
19. [Utilitários analíticos](#19-utilitários-analíticos)
20. [Escrita de arquivos — `lg.write()`](#20-escrita-de-arquivos--lgwrite)
21. [Chaves e salt — segurança](#21-chaves-e-salt--segurança)
22. [Referência completa da API](#22-referência-completa-da-api)
23. [Changelog](#23-changelog)

---

## 1. Conceitos fundamentais

### Um tipo, uma linguagem

`lg.read()` retorna `pl.DataFrame` quando Polars está instalado. Todas as funções
`lg.*` preservam o tipo de entrada — se entrou `pl.DataFrame`, sai `pl.DataFrame`.

```python
df = lg.read("arquivo.csv")          # pl.DataFrame
df = lg.where(df, uf="SP")           # pl.DataFrame (não converte para pandas)
df = lg.groupby(df, "uf", {...})      # pl.DataFrame
df = lg.mask(df, salt=SALT)          # pl.DataFrame
```

Se você passar `pd.DataFrame`, recebe `pd.DataFrame` de volta — zero round-trips.

### Engine duplo

| Operação | Engine primário | Fallback |
|---|---|---|
| Leitura CSV/Parquet/JSON/IPC/Arrow | Polars | pandas |
| Leitura Excel/SAS/SPSS/Stata/HDF5 | pandas → converte para pl.DataFrame | pandas |
| `where`, `sort`, `groupby`, `select` | Polars nativo | pandas |
| Mascaramento hash (HMAC-SHA256) | pandas dedup + map() | — |
| Mascaramento CEP, telefone, data | Polars vetorizado | — |
| `lg.sql()` | DuckDB (zero-copy Arrow) | — |

### Nomenclatura SQL/natural

logus usa nomes que fazem sentido sem saber pandas nem Polars:

| SQL | logus | Aliases |
|---|---|---|
| `WHERE` | `lg.where()` | `lg.q()` `lg.filter_()` |
| `SELECT col1, col2` | `lg.select()` | — |
| `SELECT * EXCEPT col` | `lg.drop()` | — |
| `ORDER BY col DESC` | `lg.sort(desc=True)` | `lg.order_by()` |
| `GROUP BY ... HAVING` | `lg.groupby(having=)` | `lg.group_by()` |
| `SELECT DISTINCT` | `lg.unique()` | `lg.distinct()` `lg.drop_duplicates()` |
| `LIMIT N` | `lg.head(N)` | `lg.limit()` |
| `UNION ALL` | `lg.concat()` | `lg.union_all()` |
| `COALESCE(col, val)` | `lg.fill_null(val)` | `lg.fillna()` `lg.coalesce()` |
| `CAST(col AS tipo)` | `lg.cast({'col':'tipo'})` | — |
| `CASE WHEN` | `lg.when(...).otherwise(...)` | — |
| `SELECT *, expr AS col` | `lg.add_column(col=expr)` | `lg.assign()` `lg.with_column()` |
| `TOP N PER GROUP` | `lg.top_n(N, col, group_by=)` | — |

---

## 2. Leitura de arquivos — `lg.read()`

Auto-detecta formato pela extensão. Auto-detecta encoding (UTF-8, Latin-1, CP-1252).

```python
df = lg.read("clientes.csv")
df = lg.read("clientes.xlsx")
df = lg.read("clientes.parquet")
df = lg.read("clientes.json")
df = lg.read("clientes.feather")
df = lg.read("clientes.lgs", key=KEY)        # arquivo criptografado logus

# Parâmetros opcionais
df = lg.read("arquivo.csv", sep=";")          # separador customizado
df = lg.read("arquivo.csv", encoding="latin-1")  # encoding explícito (auto se omitido)
df = lg.read("arquivo.lgs", key=KEY, salt=SALT)  # descriptografa + mascara
df = lg.read(df_existente)                    # passthrough de DataFrame
```

**Formatos suportados:**

| Extensão | Engine | Notas |
|---|---|---|
| `.csv` `.tsv` `.txt` | Polars → pandas fallback | Auto-detecta encoding |
| `.parquet` | Polars | Mais rápido (colunar, comprimido) |
| `.json` `.ndjson` `.jsonl` | Polars | |
| `.feather` `.ipc` `.arrow` | Polars | Zero-copy |
| `.avro` `.orc` | Polars | |
| `.xlsx` `.xls` `.ods` | pandas | Requer openpyxl/xlrd |
| `.xml` `.html` | pandas | |
| `.dta` | pandas | Stata |
| `.sas7bdat` `.xpt` | pandas | SAS |
| `.sav` `.zsav` | pandas | SPSS |
| `.pkl` `.hdf` `.h5` | pandas | |
| `.lgs` | logus (AES-256-GCM) | Formato nativo |

### `lg.read()` vs mascaramento

```python
# Sem salt → retorna dado bruto (como pandas/polars)
df = lg.read("clientes.csv")

# Com salt → mascara PII automaticamente ao ler
df = lg.read("clientes.csv", salt=SALT)

# Arquivo .lgs sem salt → descriptografa, retorna bruto
df = lg.read("clientes.lgs", key=KEY)

# Arquivo .lgs com salt → descriptografa + mascara
df = lg.read("clientes.lgs", key=KEY, salt=SALT)
```

---

## 3. Filtragem — `lg.where()`

```python
# kwargs — mais natural
lg.where(df, uf="SP")
lg.where(df, uf="SP", tipo_pessoa="PF")

# Lista → IS IN
lg.where(df, uf=["SP", "RJ", "MG"])

# Range → BETWEEN
lg.where(df, renda_mensal=(5000, 15000))

# Operadores explícitos
lg.where(df, renda_mensal=(">", 5000))
lg.where(df, renda_mensal=(">=", 5000))
lg.where(df, uf=("!=", "SP"))

# String operations
lg.where(df, nome=("contains", "Silva"))
lg.where(df, nome=("startswith", "Ana"))
lg.where(df, email=("endswith", "@empresa.com"))
lg.where(df, nome=("like", "%Silva%"))      # SQL LIKE (% = qualquer coisa)
lg.where(df, doc=("matches", r"\d{3}\.\d{3}"))  # regex

# Null checks
lg.where(df, documento=("isnull",))         # IS NULL
lg.where(df, documento=("notnull",))        # IS NOT NULL
lg.where(df, documento=None)                # atalho IS NULL

# lg.col() — sem import polars, com toda a expressividade
lg.where(df, lg.col("renda_mensal") > 5000)
lg.where(df, lg.col("renda_mensal").is_between(5000, 15000))
lg.where(df, lg.col("uf").is_in(["SP", "RJ"]))
lg.where(df, lg.col("nome").str.contains("Silva"))
lg.where(df, lg.col("nome").is_null())
lg.where(df, ~lg.col("inadimplente"))                       # NOT
lg.where(df, (lg.col("uf") == "SP") & (lg.col("renda_mensal") > 5000))  # AND
lg.where(df, (lg.col("uf") == "SP") | (lg.col("uf") == "RJ"))           # OR

# String query (pandas .query())
lg.where(df, 'uf == "SP" and renda_mensal > 5000')

# Callable
lg.where(df, lambda d: d["uf"] == "SP")

# pl.Expr (quando já importou polars)
lg.where(df, pl.col("uf") == "SP")

# LazyFrame → retorna LazyFrame (sem materializar)
lf = pl.scan_parquet("grande.parquet")
lf_filtrado = lg.where(lf, uf="SP")        # ainda lazy
df = lf_filtrado.collect()                  # materializa
```

**Aliases:** `lg.q()` (curto), `lg.filter_()` (não conflita com builtin `filter`)

---

## 4. Seleção e transformação de colunas

### `lg.select()` — escolhe colunas

```python
lg.select(df, "uf")
lg.select(df, ["uf", "renda_mensal", "tipo_pessoa"])
lg.select(df, lg.cols(df, "renda"))          # colunas com "renda" no nome
lg.select(df, lg.cols(df, dtype="String"))   # todas as strings
```

### `lg.drop()` — remove colunas

```python
lg.drop(df, "coluna_inutil")
lg.drop(df, ["col1", "col2"])
lg.drop(df, lg.cols(df, dtype="String", exclude=["nome"]))
```

### `lg.rename()` — renomeia colunas

```python
lg.rename(df, {"cpf": "documento", "renda": "renda_mensal"})
```

### `lg.add_column()` — adiciona ou substitui colunas

Aceita `lg.col()`, `lg.when()`, `pl.Expr`, scalar, callable, array.
Executa todas as expressões em **um único passo** via Polars.

```python
lg.add_column(df,
    # Aritmética
    imposto       = lg.col("renda_mensal") * 0.27,
    renda_liquida = lg.col("renda_mensal") * 0.73,

    # CASE WHEN
    faixa_renda   = lg.when(lg.col("renda_mensal") > 10000, "alta")
                      .when(lg.col("renda_mensal") > 5000,  "media")
                      .otherwise("baixa"),

    # Window functions (sem import polars)
    media_uf      = lg.col("renda_mensal").mean().over("uf"),
    rank_renda    = lg.col("renda_mensal").rank("dense", descending=True),

    # Acumulado
    renda_acum    = lg.col("renda_mensal").cum_sum(),

    # LAG / LEAD
    renda_anterior = lg.col("renda_mensal").shift(1),

    # String ops
    nome_lower    = lg.col("nome").str.to_lowercase(),
    nome_len      = lg.col("nome").str.len_chars(),

    # Date ops
    ano_nasc      = lg.col("data_nascimento").str.to_date("%Y-%m-%d").dt.year(),

    # Constante
    origem        = "brasil",

    # Callable (pandas fallback)
    custom        = lambda d: d["renda_mensal"] / d["idade"].clip(1, None),
)
```

**Aliases:** `lg.assign()` (pandas), `lg.with_column()` (singular)

### `lg.cast()` — converte tipos

```python
lg.cast(df, {"renda_mensal": "float32", "idade": "int32"})
lg.cast(df, {"flag": "bool", "data": "date"})
lg.cast(df, {"categoria": "categorical"})
```

Tipos: `int int8 int16 int32 int64 uint8..uint64 float float32 float64 str string bool date datetime categorical`

### `lg.fill_null()` — preenche nulos

```python
lg.fill_null(df, 0)                                      # todos os nulos → 0
lg.fill_null(df, {"renda_mensal": 0, "uf": "N/A"})      # por coluna
lg.fill_null(df, "forward")                              # propaga valor anterior
lg.fill_null(df, "backward")                             # propaga valor seguinte
```

**Aliases:** `lg.fillna()` (pandas), `lg.coalesce()` (SQL)

### `lg.clip()` — recorta valores

```python
lg.clip(df, {"renda_mensal": (1320, 500_000), "idade": (0, 120)})
lg.clip(df, {"renda": (0, None)})   # só mínimo
```

### `lg.apply()` — aplica função a colunas

```python
lg.apply(df, {"uf": str.upper, "email": str.lower, "nome": str.title})
lg.apply(df, {"renda_mensal": lambda v: round(v, 2)})
```

### `lg.cols()` — seletor de colunas por padrão

```python
lg.cols(df, "renda")                          # colunas com "renda" no nome
lg.cols(df, ["renda", "idade"])               # múltiplos padrões
lg.cols(df, dtype="String")                   # todas as strings
lg.cols(df, dtype="Float64")                  # todas as floats
lg.cols(df, dtype="String", exclude=["nome"]) # strings exceto nome

# Combina com select, drop:
lg.select(df, lg.cols(df, "renda"))
lg.drop(df, lg.cols(df, dtype="Boolean"))
```

---

## 5. Ordenação — `lg.sort()`

```python
lg.sort(df, "renda_mensal")                          # crescente
lg.sort(df, "renda_mensal", desc=True)               # decrescente
lg.sort(df, ["uf", "renda_mensal"])                  # multi-coluna crescente
lg.sort(df, ["uf", "renda_mensal"], desc=True)       # ambas decrescentes
lg.sort(df, ["uf", "renda_mensal"], ascending=[True, False])  # misto
lg.sort(df, "renda_mensal", nulls_last=False)        # nulos no início
```

**Alias SQL:** `lg.order_by()`

---

## 6. Agrupamento — `lg.groupby()`

```python
# Básico
lg.groupby(df, "uf", {"renda_mensal": "mean"})

# Múltiplas funções
lg.groupby(df, "uf", {"renda_mensal": ["mean", "sum", "min", "max"]})

# Coluna origem diferente do nome resultado
lg.groupby(df, "uf", {
    "media_renda": ("renda_mensal", "mean"),
    "total":       ("*", "count"),
    "n_pj":        ("tipo_pessoa", "count"),
})

# Multi-coluna by
lg.groupby(df, ["uf", "tipo_pessoa"], {"renda_mensal": "mean"})

# HAVING + ORDER BY + LIMIT em um passo
lg.groupby(df, "uf",
    {"media": ("renda_mensal", "mean"), "n": ("*", "count")},
    having = {"n": (">", 100)},
    sort   = "media",
    desc   = True,
    limit  = 10,
)
```

**Funções de agregação:** `mean sum min max count std var first last n_unique median`

**Alias:** `lg.group_by()`

### `lg.unique()` — remove duplicatas

```python
lg.unique(df)                   # linhas completamente únicas
lg.unique(df, "cpf")            # uma linha por CPF
lg.unique(df, ["uf", "tipo"])   # uma linha por combinação
lg.unique(df, "cpf", keep="last")   # mantém a última ocorrência
lg.unique(df, "cpf", keep="none")   # remove TODAS as duplicatas
```

**Aliases:** `lg.distinct()` (SQL), `lg.drop_duplicates()` (pandas)

### `lg.top_n()` — top N por grupo

```python
lg.top_n(df, 3, "renda_mensal")                         # top 3 global
lg.top_n(df, 3, "renda_mensal", group_by="uf")          # top 3 por UF
lg.top_n(df, 3, "renda_mensal", group_by="uf", desc=False)  # bottom 3
```

Equivalente SQL: `SELECT *, RANK() OVER (PARTITION BY uf ORDER BY renda DESC) AS r WHERE r <= 3`

### `lg.concat()` — combina DataFrames

```python
lg.concat([df_jan, df_fev, df_mar])          # empilha linhas (UNION ALL)
lg.concat([df_a, df_b], axis=1)              # combina colunas
lg.concat([df_polars, df_pandas])            # mistura tipos — retorna pl.DataFrame
```

**Alias SQL:** `lg.union_all()`

---

## 7. Expressões — `lg.col()`

`lg.col()` retorna `pl.col()` quando Polars está instalado — acesso a todos os
219 métodos nativos sem `import polars`.

```python
# Comparação
lg.col("uf") == "SP"
lg.col("uf") != "RJ"
lg.col("renda_mensal") > 5000
lg.col("renda_mensal").is_between(5000, 15000)

# Membership
lg.col("uf").is_in(["SP", "RJ", "MG"])
lg.col("doc").is_null()
lg.col("doc").is_not_null()

# String (via Polars .str namespace)
lg.col("nome").str.contains("Silva")
lg.col("nome").str.starts_with("Ana")
lg.col("email").str.ends_with("@empresa.com")
lg.col("nome").str.to_lowercase()
lg.col("nome").str.to_uppercase()
lg.col("nome").str.len_chars()
lg.col("cpf").str.replace_all(r"\D", "")  # remove não-dígitos
lg.col("cpf").str.slice(0, 3)             # substring

# Data/hora (via Polars .dt namespace)
lg.col("data_nascimento").str.to_date("%Y-%m-%d").dt.year()
lg.col("data_nascimento").str.to_date("%Y-%m-%d").dt.month()
lg.col("data_nascimento").str.to_date("%Y-%m-%d").dt.day()
lg.col("ts").dt.truncate("1mo")           # trunca para mês

# Aritmética
lg.col("renda_mensal") * 0.27
lg.col("renda_mensal") + lg.col("bonus")
lg.col("preco") - lg.col("desconto")
lg.col("valor") / lg.col("quantidade")

# Window functions
lg.col("renda_mensal").mean().over("uf")              # média por UF
lg.col("renda_mensal").rank("dense", descending=True)
lg.col("renda_mensal").rank("dense").over("uf")       # rank dentro do grupo
lg.col("renda_mensal").cum_sum()                      # acumulado
lg.col("renda_mensal").shift(1)                       # LAG(1)
lg.col("renda_mensal").shift(-1)                      # LEAD(1)
lg.col("renda_mensal").rolling_mean(window_size=7)    # média móvel 7 dias

# Combinações lógicas
~lg.col("inadimplente")                              # NOT
(lg.col("uf") == "SP") & (lg.col("renda_mensal") > 5000)  # AND
(lg.col("uf") == "SP") | (lg.col("uf") == "RJ")           # OR

# Outros
lg.lit(42)                                            # valor literal
lg.concat_str(["nome", "uf"], separator=" - ")        # concatena strings
```

---

## 8. CASE WHEN — `lg.when()`

```python
# CASE WHEN sem import polars
faixa = (
    lg.when(lg.col("renda_mensal") > 10000, "alta")
      .when(lg.col("renda_mensal") > 5000,  "media")
      .otherwise("baixa")
)

# Uso em add_column
df2 = lg.add_column(df,
    faixa_renda = lg.when(lg.col("renda_mensal") > 10000, "alta")
                    .when(lg.col("renda_mensal") > 5000, "media")
                    .otherwise("baixa"),
    categoria   = lg.when(lg.col("inadimplente"), "devedor")
                    .otherwise("regular"),
    adulto      = lg.when(lg.col("idade") >= 18, True).otherwise(False),
)

# Com expressão comparando colunas
df3 = lg.add_column(df,
    acima_media = lg.when(
        lg.col("renda_mensal") > lg.col("renda_mensal").mean().over("uf"),
        "acima"
    ).otherwise("abaixo")
)
```

---

## 9. Pipeline fluente — `lg.pipe()`

Evita variáveis temporárias. Encadeia operações de forma legível.

```python
result = (
    lg.pipe("clientes.parquet")            # lê arquivo
    .where(tipo_pessoa="PF", uf="SP")      # filtra
    .add_column(
        imposto    = lg.col("renda_mensal") * 0.27,
        faixa      = lg.when(lg.col("renda_mensal") > 10000, "alta")
                       .when(lg.col("renda_mensal") > 5000,  "media")
                       .otherwise("baixa"),
        media_uf   = lg.col("renda_mensal").mean().over("uf"),
    )
    .mask(salt=SALT)                       # mascara PII
    .groupby(
        "faixa",
        {"renda_media": ("renda_mensal", "mean"), "n": ("*", "count")},
        having={"n": (">", 1000)},
        sort="renda_media",
        desc=True,
    )
    .collect()                             # retorna pl.DataFrame
)

# Com arquivo .lgs (descriptografa automaticamente)
result = (
    lg.pipe("clientes.lgs", key=KEY)
    .where(tipo_pessoa="PF")
    .mask(salt=SALT)
    .store("clientes_sp_masked.lgs", key=KEY)  # salva sem .collect()
)

# Com SQL
result = (
    lg.pipe()
    .sql("SELECT * FROM read_parquet('dados.parquet') WHERE uf='SP'")
    .mask(salt=SALT)
    .collect()
)
```

**Métodos disponíveis no pipeline:**
`read`, `sql`, `where`, `select`, `drop`, `rename`, `sort`, `groupby`,
`add_column`, `cast`, `fill_null`, `drop_duplicates`, `head`, `tail`,
`mask`, `scan`, `profile`, `store`, `collect`, `to_pandas`, `to_polars`

---

## 10. SQL direto — `lg.sql()`

Executa SQL em DataFrames via DuckDB. Zero-copy via Arrow para `pl.DataFrame`.

```python
# SQL em DataFrame em memória
result = lg.sql(
    "SELECT uf, AVG(renda_mensal) AS media, COUNT(*) AS n "
    "FROM df GROUP BY uf HAVING n > 100 ORDER BY media DESC",
    df=df
)

# JOIN entre múltiplos DataFrames
result = lg.sql(
    "SELECT c.uf, c.renda_mensal, p.valor "
    "FROM clientes c JOIN pedidos p ON c.documento = p.documento",
    clientes=df_clientes,
    pedidos=df_pedidos,
)

# Lê Parquet/CSV diretamente com SQL (sem carregar em Python)
result = lg.sql(
    "SELECT * FROM read_parquet('clientes.parquet') WHERE uf='SP'"
)
result = lg.sql(
    "SELECT * FROM read_csv('dados.csv') WHERE renda > 5000"
)

# Arquivo .lgs descriptografado automaticamente
result = lg.sql(
    "SELECT uf, COUNT(*) AS n FROM base GROUP BY uf",
    base="clientes.lgs",
    key=KEY,
)

# Com mascaramento PII no resultado
result = lg.sql("SELECT * FROM df", df=df, salt=SALT)
```

> Requer: `pip install duckdb`

---

## 11. Privacidade — `lg.scan()` e `lg.mask()`

### `lg.scan()` — detecta PII

```python
# Aceita arquivo, pd.DataFrame ou pl.DataFrame
reports = lg.scan(df)
reports = lg.scan("clientes.parquet")
reports = lg.scan("clientes.lgs", key=KEY)

# Relatório por coluna
for col, r in reports.items():
    print(f"{col}: tipo={r.pii_type.value} risco={r.risk_level.value} estratégia={r.mask_strategy.value}")

# Tipos detectados automaticamente:
# cpf, cnpj, email, telefone, cep, data_nascimento, nome, rg, ip,
# cartao_credito, quasi_identifier, numerico, categorico
```

**Estratégias de mascaramento por tipo:**

| Tipo | Estratégia | Exemplo antes → depois |
|---|---|---|
| CPF / CNPJ | `hash` | `111.444.777-35` → `3425441ddfb8d1ec` |
| E-mail | `hash` | `ana@empresa.com` → `7a3f9c1d4e2b8f56` |
| Nome | `redact` | `Ana Silva` → `REDACTED` |
| Telefone | `mask_phone_ddd` | `(11) 98765-4321` → `(11) XXXXX-XXXX` |
| CEP | `truncate` | `01310-100` → `01310-XXX` |
| Data de nascimento | `generalize_date` | `1985-03-15` → `1980-1989` |
| UF / categoria | `mock_category` | `SP` → `RJ` (aleatório) |
| Renda / numérico | `mock_numeric` | `5000.00` → `4937.12` (perturbado) |

### `lg.mask()` — mascara PII

```python
# Mascaramento completo (detecta e mascara automaticamente)
df_safe = lg.mask(df, salt=SALT)

# Colunas específicas
df_safe = lg.mask(df, salt=SALT, columns=["cpf", "email"])

# Excluir colunas
df_safe = lg.mask(df, salt=SALT, exclude=["uf", "tipo_pessoa"])

# Relatório do que foi mascarado
df_safe = lg.mask(df, salt=SALT, verbose=True)

# Aceita pd.DataFrame e pl.DataFrame — retorna o mesmo tipo
df_pl_safe = lg.mask(df_pl, salt=SALT)   # recebe/retorna pl.DataFrame
df_pd_safe = lg.mask(df_pd, salt=SALT)   # recebe/retorna pd.DataFrame
```

**Determinismo:** o mesmo CPF com o mesmo salt sempre gera o mesmo token.
Essencial para JOINs entre tabelas mascaradas.

```python
token_a = lg.mask(df_a, salt=SALT)["cpf"]
token_b = lg.mask(df_b, salt=SALT)["cpf"]
# token_a["111.444.777-35"] == token_b["111.444.777-35"]  # sempre True
```

**Normalização:** diferentes formatações do mesmo CPF geram o mesmo token.

```python
# "111.444.777-35", "11144477735", "111-444-777.35" → mesmo token
```

**Nulos:** `None`, `""`, `"NaN"`, `"none"`, `"null"` → mantidos como nulos (não hasheados).

### `lg.diff()` — compara antes × depois

```python
diff = lg.diff(df_original, df_mascarado)
print(diff["summary"])
print(diff["columns_changed"])
print(diff["columns_unchanged"])
```

---

## 12. Formato `.lgs` — arquivo seguro

AES-256-GCM com autenticação de integridade. Suporte a múltiplos DataFrames.

```python
KEY  = lg.generate_salt()   # chave AES (256 bits)
SALT = lg.generate_salt()   # salt HMAC (diferente da KEY!)

# Grava — variações
lg.store(df, "f.lgs", key=KEY)                      # cifra sem mascarar
lg.store(df, "f.lgs", key=KEY, salt=SALT)           # mascara + cifra em 1 operação
lg.store(df, "f.lgs", anonymize=True)               # só mascara, sem criptografia
lg.store(df, "f.lgs")                               # sem cripto (avisa se tiver PII)

# Com metadados
lg.store(df, "f.lgs", key=KEY, salt=SALT,
         metadata={"origem": "crm", "versao": "3", "squad": "dados"},
         overwrite=True)

# Lê
df = lg.read("f.lgs", key=KEY)                     # bruto
df = lg.read("f.lgs", key=KEY, salt=SALT)           # + mascara
df = lg.read("f.lgs", key=KEY, raw=True)            # sem mascara adicional

# Inspeciona sem descriptografar
info = lg.inspect("f.lgs", key=KEY)
print(info["content_type"])    # raw_dataframe | masked_dataframe | anonymous_dataframe
print(info["shape"])
print(info["metadata"])

# Rotação de chave (sem descriptografar em disco)
lg.rekey("f.lgs", old_key=KEY_ANTIGO, new_key=KEY_NOVO)
```

**Aliases:** `lg.save = lg.store`, `lg.load = lg.read`

---

## 13. Multi-frame e `lg.open()`

```python
# Salva múltiplos DataFrames em um arquivo
lg.store(
    {"clientes": df_clientes, "pedidos": df_pedidos, "pagamentos": df_pagamentos},
    "base_producao.lgs",
    key=KEY, salt=SALT,
    metadata={"ambiente": "producao", "versao_schema": "4"},
)

# Lê todos os frames
frames = lg.read("base_producao.lgs", key=KEY)
df_clientes  = frames["clientes"]
df_pedidos   = frames["pedidos"]

# Lê um frame específico (sem carregar os demais)
df_c = lg.read("base_producao.lgs", key=KEY, frame="clientes")

# Context manager
with lg.open("base_producao.lgs", key=KEY) as f:
    print(f.frame_names())     # ["clientes", "pedidos", "pagamentos"]
    print(f.shape())
    print(f.info())

    df = f.read()              # todos os frames
    df = f.frame("clientes")   # frame específico
    f.add_frame("logs", df_logs)  # adiciona frame

# Verificação de integridade
from logus.secure_file import SecureFile
info = SecureFile.verify("f.lgs", key=KEY)
if not info:
    raise RuntimeError("Arquivo corrompido ou chave errada!")
print(info.content_type, info.shape, info.encryption)
```

---

## 14. JOIN seguro — `lg.join()`

`lg.join()` aplica o mesmo mascaramento em ambas as tabelas antes do JOIN,
garantindo que o CPF `111.444.777-35` vire o mesmo token nas duas.

```python
# JOIN seguro (mascara antes de juntar)
resultado = lg.join(
    df_clientes, df_pedidos,
    on="cpf",
    salt=SALT,
    how="inner",   # inner | left | right | full
)

# Com tabelas já mascaradas
resultado = lg.join(df_clientes_safe, df_pedidos_safe, on="cpf")
# lg.join() valida que os tokens são compatíveis (mesmo salt)
```

---

## 15. Diagnóstico — `lg.profile()` e `lg.diff()`

```python
# Perfil completo (JSON-serializable)
report = lg.profile(df)
report = lg.profile("clientes.parquet")
report = lg.profile("clientes.lgs", key=KEY)

print(report["shape"])                # (1000000, 11)
print(report["pii_columns"])          # ["cpf", "email", "nome", ...]
print(report["n_pii_columns"])        # 6
print(report["pii_risk_summary"])     # {"high": 3, "medium": 2, "low": 1}
print(report["null_pct"])             # 2.4
print(report["nunique"])              # {"uf": 20, "cpf": 999619, ...}

# JSON para log / SIEM
import json
json_str = json.dumps(report, ensure_ascii=False)

# Diff antes × depois
diff = lg.diff(df_original, df_mascarado)
print(diff["summary"])
print(diff["columns_changed"])

# Info rápido (como df.info())
lg.info(df)
```

---

## 16. Verificação de privacidade — `lg.check`

```python
df_safe = lg.mask(df, salt=SALT)

# k-anonimato (ANPD recomenda k >= 5)
r = lg.check.kanon(
    df_safe,
    quasi_identifiers=["uf", "idade", "tipo_pessoa"],
    target_k=5,
)
print(f"k = {r.k_anonymity.k_value}")
print(f"Conforme ANPD (k≥5): {r.compliant_anpd}")

# Risco de re-identificação
r2 = lg.check.risk(
    df_safe,
    quasi_identifiers=["uf", "idade"],
    masked_columns=["cpf", "email", "nome"],
)
print(f"Risco: {r2.risk_score:.3f} ({r2.risk_level})")

# Utilidade preservada
r3 = lg.check.utility(df_original, df_safe)
print(f"Utilidade: {r3.overall_score:.1%}")
```

---

## 17. Streaming — `lg.stream()`

Para arquivos que não cabem em memória.

```python
# Processa em chunks sem OOM
for chunk in lg.stream("grande.csv", salt=SALT, chunksize=50_000):
    # chunk é pd.DataFrame mascarado
    salvar_no_banco(chunk)

# Com callback de progresso
def progresso(n_chunk, linhas_feitas, total_estimado):
    print(f"Chunk {n_chunk}: {linhas_feitas:,} linhas")

for chunk in lg.stream("grande.csv", salt=SALT,
                        chunksize=50_000, on_progress=progresso):
    processar(chunk)
```

---

## 18. Banco de dados — `lg.read_db()` e `link`

```python
# Lê de banco
df = lg.read_db("postgresql://user:pass@host/db",
                "SELECT * FROM clientes WHERE uf='SP'",
                salt=SALT)

# Interface completa via link
from logus import link

adapter = link.db("postgresql://user:pass@host/db", salt=SALT)

# Query
df = adapter.query("SELECT * FROM clientes LIMIT 1000")

# Escreve
adapter.write(df_safe, "clientes_masked", if_exists="replace")

# Mascaramento direto no banco (sem round-trip Python)
result = adapter.in_db_mask("clientes", dry_run=True)   # revisa SQLs primeiro
adapter.create_masked_view("clientes")                  # cria VIEW mascarada
```

---

## 19. Utilitários analíticos

```python
# Shape e schema
lg.shape(df)         # (1000000, 11)
lg.schema(df)        # {'nome': 'String', 'renda_mensal': 'Float64', ...}
lg.dtypes(df)        # alias de schema()
lg.info(df)          # imprime resumo com tipos e nulos

# Contagem
lg.count(df)         # total de linhas
lg.count(df, "cpf")  # não-nulos na coluna cpf

# Nulos
lg.count_nulls(df)   # pd.Series com contagem por coluna
lg.null_counts(df)   # alias

# Únicos
lg.nunique(df)       # pd.Series com n_unique por coluna
lg.value_counts(df, "uf")
lg.value_counts(df, "uf", normalize=True, n=5)

# Estatísticas
lg.describe(df)      # pd.DataFrame com estatísticas descritivas
lg.corr(df)          # correlação entre colunas numéricas

# Pivot / reshape
lg.pivot(df, index="uf", columns="tipo_pessoa",
         values="renda_mensal", aggfunc="mean")
lg.melt(df, id_cols=["uf"], value_cols=["renda_mensal", "idade"])
lg.unpivot(df, ...)  # alias de melt()

# Amostra
lg.sample(df, n=1000)
lg.sample(df, frac=0.1, seed=42)

# Head / tail
lg.head(df, 10)
lg.tail(df, 5)
lg.limit(df, 10)     # alias de head()

# Conversão de tipo
lg.to_pandas(df)     # pl.DataFrame → pd.DataFrame (passthrough se já pandas)
lg.to_polars(df)     # pd.DataFrame → pl.DataFrame (passthrough se já polars)
```

---

## 20. Escrita de arquivos — `lg.write()`

```python
# Auto-detecta formato pela extensão
lg.write(df, "resultado.csv")
lg.write(df, "resultado.parquet")
lg.write(df, "resultado.xlsx")
lg.write(df, "resultado.json")
lg.write(df, "resultado.feather")

# Com opções
lg.write(df, "resultado.csv", separator=";")  # CSV europeu

# Para arquivos .lgs (criptografados), use lg.store()
lg.store(df, "resultado.lgs", key=KEY)
lg.store(df, "resultado.lgs", key=KEY, salt=SALT)  # mascara + cifra
```

---

## 21. Chaves e salt — segurança

### Conceito

| | Salt (HMAC) | Key (AES) |
|---|---|---|
| **Para quê** | Mascaramento determinístico | Criptografia do arquivo |
| **Quem vê** | Quem precisa fazer JOINs | Quem precisa ler os dados |
| **Se vazar** | Tokens podem ser revertidos por força bruta | Dados ficam expostos |
| **Rotação** | Exige re-mascarar todos os dados | `lg.rekey()` (sem decifrar) |

### Geração segura

```python
# CORRETO — 256 bits de entropia real
SALT = lg.generate_salt()    # 48 chars, ~240 bits
KEY  = lg.generate_salt()    # use salt diferente para cada

# Em produção — variáveis de ambiente ou vault
import os
SALT = os.environ["LOGUS_SALT"]
KEY  = os.environ["LOGUS_KEY"]
```

### Requisitos do salt

| Requisito | Detalhes | Erro |
|---|---|---|
| Mínimo 16 bytes (128 bits) | Menos que isso → `ValueError` | `ValueError` |
| Mínimo 6 caracteres distintos | Ex: `"aaaaaaaaaaaaaaaa"` → aviso | `UserWarning` |
| Entropia Shannon ≥ 2.0 bits | Muito repetitivo → aviso | `UserWarning` |
| Sem palavras de dicionário | `"password123..."` → aviso | `UserWarning` |
| Sem anos (ex: `2024`) | Reduz espaço de busca | `UserWarning` |

```python
# ERRADO — lança ValueError (muito curto)
lg.mask(df, salt="curto")

# ERRADO — UserWarning (fraco mas funciona)
lg.mask(df, salt="aaaaaaaaaaaaaaaa")     # só 1 caractere único
lg.mask(df, salt="senha123senha123")     # palavra de dicionário

# CORRETO
lg.mask(df, salt=lg.generate_salt())
```

### `lg.generate_salt()`

```python
SALT = lg.generate_salt()              # 48 chars hex (256 bits)
HEX  = lg.generate_salt_hex()         # alternativa hex puro
```

---

## 22. Referência completa da API

### 🔐 Privacidade (núcleo exclusivo do logus)

| Função | Descrição |
|---|---|
| `lg.scan(source, *, key, sample_size, threshold)` | Detecta PII em DataFrame, arquivo ou .lgs |
| `lg.mask(df, *, salt, columns, exclude, verbose)` | Mascara PII com HMAC-SHA256 |
| `lg.profile(source, *, key)` | Diagnóstico JSON-serializable |
| `lg.diff(original, masked)` | Compara antes × depois |
| `lg.join(left, right, on, *, salt, how)` | JOIN seguro com tokens compatíveis |
| `lg.check.kanon(df, quasi_identifiers, target_k)` | k-anonimato (ANPD k≥5) |
| `lg.check.risk(df, quasi_identifiers, masked_columns)` | Risco de re-identificação |
| `lg.check.utility(original, masked)` | Utilidade preservada (0–1) |

### 📁 Leitura e Escrita

| Função | Descrição |
|---|---|
| `lg.read(source, *, key, salt, raw, frame)` | Lê qualquer formato + .lgs |
| `lg.store(source, path, *, key, salt, metadata)` | Salva como .lgs (AES-256-GCM) |
| `lg.write(df, path, **kwargs)` | Escreve CSV/Parquet/Excel/JSON (sem cripto) |
| `lg.open(path, *, key, salt)` | LGSFile context manager |
| `lg.inspect(path, *, key)` | Metadados sem descriptografar |
| `lg.rekey(path, *, old_key, new_key)` | Rotação de chave |
| `lg.stream(source, *, salt, chunksize, on_progress)` | Chunks sem OOM |
| `lg.read_db(url, sql, *, salt)` | Lê de banco relacional |
| `lg.save` / `lg.load` | Aliases de store/read |

### 🔍 WHERE / Filtragem

| Função | Descrição |
|---|---|
| `lg.where(df, expr, **kwargs)` | Filtra linhas (todas as sintaxes) |
| `lg.q(df, ...)` | Alias curto de where() |
| `lg.filter_(df, ...)` | Alias (não conflita com builtin) |
| `lg.query(df, ...)` | Alias backward compat |

### 📋 SELECT / Colunas

| Função | Descrição |
|---|---|
| `lg.select(df, cols)` | Seleciona colunas |
| `lg.drop(df, cols)` | Remove colunas |
| `lg.rename(df, mapping)` | Renomeia colunas |
| `lg.cols(df, pattern, *, dtype, exclude)` | Lista colunas por padrão/tipo |
| `lg.add_column(df, **cols)` | Adiciona/substitui colunas |
| `lg.with_column(df, ...)` | Alias de add_column() |
| `lg.assign(df, ...)` | Alias pandas de add_column() |
| `lg.cast(df, schema)` | Converte tipos |
| `lg.fill_null(df, value)` | Preenche nulos |
| `lg.fillna(df, ...)` | Alias pandas |
| `lg.coalesce(df, ...)` | Alias SQL |
| `lg.clip(df, bounds)` | Recorta valores numéricos |
| `lg.apply(df, funcs)` | Aplica função a colunas |

### 📊 ORDER BY / GROUP BY

| Função | Descrição |
|---|---|
| `lg.sort(df, by, *, desc, ascending, nulls_last)` | Ordena |
| `lg.order_by(df, ...)` | Alias SQL |
| `lg.groupby(df, by, agg, *, having, sort, desc, limit)` | Agrupa + agrega |
| `lg.group_by(df, ...)` | Alias polars |
| `lg.unique(df, subset, *, keep)` | Remove duplicatas |
| `lg.distinct(df, ...)` | Alias SQL |
| `lg.drop_duplicates(df, ...)` | Alias pandas |
| `lg.top_n(df, n, by, *, group_by, desc)` | Top N por grupo |
| `lg.head(df, n)` | Primeiras N linhas |
| `lg.tail(df, n)` | Últimas N linhas |
| `lg.limit(df, n)` | Alias SQL de head() |
| `lg.sample(df, n, frac, *, seed)` | Amostra aleatória |
| `lg.concat(frames, *, axis)` | Concatena DataFrames |
| `lg.union_all(...)` | Alias SQL de concat() |
| `lg.pivot(df, *, index, columns, values, aggfunc)` | Wide pivot |
| `lg.melt(df, *, id_cols, value_cols, name, value)` | Long unpivot |
| `lg.unpivot(df, ...)` | Alias polars de melt() |

### 📈 INFO / Estatísticas

| Função | Descrição |
|---|---|
| `lg.describe(df)` | Estatísticas descritivas |
| `lg.info(df)` | Resumo: shape, tipos, nulos |
| `lg.schema(df)` | Schema: {col: tipo} |
| `lg.dtypes(df)` | Alias de schema() |
| `lg.shape(df)` | (linhas, colunas) |
| `lg.count(df, col)` | Linhas ou não-nulos |
| `lg.count_nulls(df)` | Nulos por coluna |
| `lg.null_counts(df)` | Alias |
| `lg.nunique(df)` | Únicos por coluna |
| `lg.value_counts(df, col, *, normalize, n)` | Frequência de valores |
| `lg.corr(df)` | Correlação |

### 🔧 Expressões

| Função | Descrição |
|---|---|
| `lg.col(name)` | Expressão de coluna (= pl.col quando Polars disponível) |
| `lg.lit(value)` | Valor literal (= pl.lit) |
| `lg.concat_str(cols, separator)` | Concatena colunas string |
| `lg.when(condition, value)` | Inicia CASE WHEN |

### 🔄 Conversão de tipo

| Função | Descrição |
|---|---|
| `lg.to_pandas(df)` | pl.DataFrame → pd.DataFrame |
| `lg.to_polars(df)` | pd.DataFrame → pl.DataFrame |

### ⚡ Pipeline e SQL

| Função | Descrição |
|---|---|
| `lg.pipe(source, *, key, salt)` | Pipeline fluente |
| `lg.sql(query, *, salt, key, **frames)` | SQL via DuckDB |

### 🔑 Segurança

| Função | Descrição |
|---|---|
| `lg.generate_salt()` | Gera salt seguro (256 bits) |
| `lg.generate_salt_hex()` | Variante hex |
| `lg.configure(*, audit, audit_path)` | Configura auditoria |

### 🗄️ Banco de dados

| Objeto | Descrição |
|---|---|
| `lg.link.db(url, salt)` | Adapter SQL com privacidade |
| `adapter.query(sql, params)` | Executa query |
| `adapter.write(df, table)` | Escreve no banco |
| `adapter.in_db_mask(table, dry_run)` | Mascara no banco |
| `adapter.create_masked_view(table)` | Cria VIEW mascarada |

---

## 23. Changelog

### v1.1.0 (2024)

**Arquitetura — engine duplo sem round-trip:**
- `lg.where()`, `lg.sort()`, `lg.groupby()` etc. agora preservam o tipo de entrada:
  `pl.DataFrame` entra → `pl.DataFrame` sai, sem conversão para pandas
- Polars como engine primário em todos os `analytics.*`; pandas permanece para `pd.DataFrame`

**Novos recursos:**
- `lg.top_n(df, n, by, group_by=)` — TOP N por grupo via window function
- `lg.when().when().otherwise()` — CASE WHEN sem import polars
- `lg.add_column(**cols)` — adiciona colunas com kwargs, aceita lg.col(), lg.when(), pl.Expr
- `lg.pipe(source)` — pipeline fluente encadeado
- `lg.sql(query, *, df=, key=, salt=)` — SQL via DuckDB (zero-copy Arrow)
- `lg.cols(df, pattern, dtype=)` — seletor de colunas por padrão/tipo
- `lg.write(df, path)` — escreve qualquer formato detectando pela extensão
- `lg.fill_null()` com `"forward"/"backward"` e dict por coluna
- `lg.clip()`, `lg.apply()`

**Nomenclatura SQL completa:**
- `lg.q()` (alias curto de where), `lg.order_by()`, `lg.group_by()`,
  `lg.distinct()`, `lg.union_all()`, `lg.coalesce()`, `lg.limit()`, `lg.unpivot()`

**Expressões — `lg.col() = pl.col()`:**
- `lg.col()` agora retorna `pl.col()` diretamente — acesso a todos os 219 métodos Polars
- `lg.lit()` e `lg.concat_str()` expostos como funções de módulo

**Performance:**
- Detecção de PII: 5.9s → 33ms para 1M linhas (`detect_sampled`)
- Mascaramento telefone: `(XX) XXXXX-XXXX` correto, vetorizado (10× mais rápido)
- Mascaramento data: `str.slice` Polars nativo (1717ms → 190ms por 1M linhas)
- `lg.sort()` overhead: +3.8ms vs `df.sort()` nativo (Python dispatch)

**Segurança — salt:**
- Verificação de entropia Shannon: salts como `"aaaa..."` agora geram `UserWarning`
- Verificação de caracteres únicos: mínimo 6 distintos
- Mensagem de erro melhorada com instrução de correção

**Correções:**
- `lg.read()` sem salt não mascara mais automaticamente (era bug de design)
- `lg.mask()` em `pd.DataFrame` retorna `pd.DataFrame` (antes retornava `pl.DataFrame`)
- Mascaramento de string vazia `""` e `"NaN"` → trata como `null` (não hasheia)
- `lg.groupby(having=)` funciona para LazyFrame

### v1.0.3

- `lg.col() = pl.col()` com fallback `logus.expr.Col` sem Polars
- `lg.where()` aceita kwargs, lista (is_in), operadores tupla, `lg.col()`, `pl.Expr`, string, callable
- `lg.groupby(having=, sort=, desc=, limit=)` — GROUP BY completo
- `lg.sql()` via DuckDB
- `lg.pipe()` fluente
- Encoding auto-detecção (latin-1, cp1252, utf-8-sig)
- Nulos normalizados: `""`, `"NaN"`, `"none"` → `null` antes do hash

### v1.0.2

- `lg.col()` com operadores de comparação e lógicos
- `lg.add_column()` com kwargs
- `lg.when().otherwise()` — CASE WHEN
- `lg.drop_duplicates()`, `lg.concat()`, `lg.pivot()`, `lg.melt()`
- `lg.to_pandas()`, `lg.to_polars()`

### v1.0.1

- Encoding auto-detecção para CSV
- `lg.read()` sem salt → retorna dado bruto (breaking change intencional)
- `lg.store(key=, salt=)` → mascara + cifra em uma operação
- `lg.pl.read()` = `lg.read()` (mesmo resultado)

### v1.0.0

- `lg.read()` com auto-detecção de formato
- Mascaramento Polars-first com preservação de tipo
- Formato `.lgs` AES-256-GCM multi-frame
- `lg.where()` com dict, string, callable
- `lg.scan()` com `detect_sampled()` (100× mais rápido)
- `lg.check.kanon()`, `lg.check.risk()`, `lg.check.utility()`
- `lg.join()` seguro
- `lg.stream()` sem OOM
