logic
The logic module contains pure, stateless transformation functions with no infrastructure dependencies. Functions here take inputs and produce outputs without side effects.
Entity Aggregation
Aggregate a stream of statement dicts into FollowTheMoney entity dicts:
from ftm_lakehouse.logic.entities import aggregate_unsafe
for entity in aggregate_unsafe(statement_dicts, "my_dataset"):
print(f"{entity['id']}: {entity['caption']}")
aggregate_unsafe assumes the input is pre-sorted by entity_id – the parquet store guarantees this for its queries.
ftm_lakehouse.logic.entities.aggregate.aggregate_unsafe(data, dataset=None)
Aggregate statement dicts (e.g. from DuckDB rows) to entity payloads.
Completely circumvents the dict -> Statement -> StatementEntity -> dict Python path, but therefore has no validation checks. Input must be sorted by entity_id.
Source code in ftm_lakehouse/logic/entities/aggregate.py
Parquet helpers
The DuckDB config, the statement / statement_raw view-SQL builders, and the merge-query builder used by ParquetStore via ftmq's LakeStore.
ftm_lakehouse.logic.parquet.duckdb_config()
LakeStore DuckDB config derived from lakehouse settings.
Per-query memory is bounded by Settings.duckdb_memory_limit
(env: LAKEHOUSE_DUCKDB_MEMORY_LIMIT, default 8GB); queries
exceeding the limit spill to Settings.duckdb_temp_directory
(env: LAKEHOUSE_DUCKDB_TEMP_DIRECTORY) when set, otherwise to
the OS temp directory DuckDB picks by default. Extensions (notably
delta) are loaded from Settings.duckdb_extension_directory
(env: LAKEHOUSE_DUCKDB_EXTENSION_DIRECTORY) when set, otherwise
from $HOME/.duckdb/extensions. Passed to
LakeStore via the duckdb_config kwarg.
Source code in ftm_lakehouse/logic/parquet.py
ftm_lakehouse.logic.parquet.raw_view_sql(dt)
SELECT body for the statement_raw view.
Surfaces every physical row in the Delta table, including
tombstones and pre-merge duplicates. Used by build_merge_sql
and raw-source queries (diff exports) – any path that needs the
physical layout visible.
Source code in ftm_lakehouse/logic/parquet.py
ftm_lakehouse.logic.parquet.live_view_sql(dt)
SELECT body for the live statement view.
On a store kept canonical by build_merge_sql (one row per
statement id, fragment supersession applied, first_seen /
last_seen folded) the live rows are simply the non-tombstoned
physical rows – so the view is a plain filtered scan, no window
function. Predicate pushdown works natively: schema / prop /
entity_id filters reach delta_scan's per-file statistics (a
window would be a pushdown barrier for any non-partition column).
canonical_id is not stored – this is a single-dataset store with no
entity resolution, so it always equals entity_id – and is synthesised
here as entity_id AS canonical_id so ftmq's query layer (which keys
entity identity on canonical_id) resolves against the view unchanged.
raw_view_sql deliberately omits it so merge never materialises
the duplicate column.
Correctness holds only on an optimized store: between a write and
the next merge this view can surface duplicate ids and rows
whose delete has not been applied yet. Run optimize before
querying – the dedupe / supersession / grace logic lives solely in
build_merge_sql.
Source code in ftm_lakehouse/logic/parquet.py
Both builders emit delta_scan('<uri>'), so a view defined from this SQL resolves the current Delta log on every query – defining it once per connection is enough; subsequent write_deltalake commits are picked up automatically. The live statement view is a plain WHERE deleted_at IS NULL scan (no window function, so predicate pushdown survives) and is only correct on an optimized store; statement_raw exposes every physical row – tombstones and pre-merge duplicates included – for merge and raw-source get_entity_ids queries (diff exports).
ftm_lakehouse.logic.parquet.build_merge_sql(shard, bucket, origin, grace_cutoff, entity_id_range=(None, None))
DuckDB SQL that collapses one partition for physical merge.
_dedupe_sql over the raw statement_raw view (not the
deduped statement) because merge needs every row visible –
including tombstones within the grace window, which must persist
physically to keep shadowing their live rows – scoped to one
(shard, bucket, origin) partition. Output is ordered by
(entity_id, fragment, role, prop, id, last_seen DESC) – the file sort
key – so the rewritten parquet file is ready for future merges
without re-sort.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shard
|
str
|
Target shard value (hex-padded). |
required |
bucket
|
str
|
Target bucket ( |
required |
origin
|
str
|
Target origin tag – re-validated here, so it is safe to
interpolate: |
required |
grace_cutoff
|
datetime
|
Tombstones with |
required |
entity_id_range
|
tuple[str | None, str | None]
|
Optional half-open |
(None, None)
|
Returns:
| Type | Description |
|---|---|
str
|
Executable DuckDB SQL. |
Source code in ftm_lakehouse/logic/parquet.py
An executable DuckDB SQL string over statement_raw holding all dedupe / fragment-supersession logic; it collapses one (shard, bucket, origin) partition for physical rewrite. Change-detection for diff exports no longer has its own SQL builder – it is an ftmq Query over the raw source (ParquetStore.get_entity_ids(q, source=store.source_raw)).
ftm_lakehouse.logic.parquet.build_shard_sql(shard, bucket, origin, shards)
DuckDB SQL re-keying one partition's rows onto shards shards.
SELECT * over the raw statement_raw view (tombstones and
pre-merge duplicates included – a re-shard moves rows, it does not
decide what survives) with the stored shard swapped for the one
shard_expr_sql computes from entity_id. REPLACE keeps
the projection positional, so the result still is
SHARDED_SCHEMA and streams
straight into write_deltalake.
Deliberately unordered and un-deduped: the caller
(shard) re-stamps
every rewritten partition as dirty, so the next merge restores the
file sort order – paying for a sort here would only make the rewrite
slower.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shard
|
str
|
Source shard value (hex-padded) to read. |
required |
bucket
|
str
|
Source bucket – invariant under re-sharding. |
required |
origin
|
str
|
Source origin tag – invariant under re-sharding. Re-validated here, so it is safe to interpolate. |
required |
shards
|
int
|
Target shard count. |
required |
Returns:
| Type | Description |
|---|---|
str
|
Executable DuckDB SQL. |
Source code in ftm_lakehouse/logic/parquet.py
ftm_lakehouse.logic.parquet.shard_expr_sql(shards, column='entity_id')
DuckDB expression computing the shard key of column.
The SQL twin of helpers.shards.entity_shard,
used by build_shard_sql so a re-shard recomputes every row's
partition inside DuckDB's vectorised pipeline instead of marshaling
ids into Python. banal.hash_data of a str is a plain SHA-1 of
its UTF-8 bytes, which is exactly what DuckDB's sha1() returns –
the two are pinned to agree by
tests/test_logic_parquet.py::test_shard_expr_sql_parity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shards
|
int
|
Target shard count; |
required |
column
|
str
|
Column holding the entity id. |
'entity_id'
|
Returns:
| Type | Description |
|---|---|
str
|
A DuckDB scalar expression yielding the hex-padded shard key. |
Source code in ftm_lakehouse/logic/parquet.py
The other partition rewrite: build_shard_sql re-keys a partition's rows onto a new shard count for ParquetStore.shard, recomputing each row's shard in DuckDB. shard_expr_sql is the SQL twin of helpers.shards.entity_shard – banal.hash_data of a string is a plain SHA-1 over its UTF-8 bytes, which is what DuckDB's sha1() returns, and a parity test pins the two together.
Statement Serialization
Statements are packed once, columnwise, by ftm_lakehouse.model.statement.statements_to_arrow – see Model.