Product

Idea Codex

A pipeline that collects market signals from four public sources, refines them into queryable tables, and keeps every row attached to the run that produced it.

What it collects, and what it refuses to

Four public sources: Reddit, Hacker News, Steam and Google Trends. Each one has an adapter behind a shared port, and every payload lands in a raw lake under a versioned envelope before anything reads it.

The rule that shapes the rest is that an unsourced number is worth zero. Claims that cannot be traced to a stored payload are marked [UNCITED] and carry no weight in scoring. That is not internal hygiene: the thing being built on top is an analyst that has to be checkable, and a figure nobody can walk back is the failure it exists to avoid.

5

Bounded contexts

Collection, refinement, analysis, candidates, content safety.

152

Test files

Against 221 source files, under strict typing.

9

Import fences

Contracts a linter checks, so a layering slip fails CI.

Refinement and analysis are different jobs

The boundary took a decision record to settle. Refinement derives attributes of a thing: cleaning, noun-chunk extraction, sentiment, velocity. Analysis derives structure between things: the demand graph, orderings, clusters.

Putting sentiment in analysis would have been easier and wrong. It describes one row, so a rebuild of one source has to recompute it, and a graph spanning four sources must not.

Sampling that costs less the longer it runs

Snapshot density is driven by score, not by a schedule. Something moving fast gets resampled often; something nobody reads falls out of the rotation. A Steam review is scored against the popularity of the game it sits on, inversely, so a handful of enormous titles cannot crowd out the queue.

Paid sources are metered per pull against a spend log, and a paid source is only reached for after a free one has been tried and written up.

KeyDirect path
  1. Collection

    • Source collectors

      Python

      Why

      Reddit, Hacker News, Steam and Google Trends. One adapter each, behind one port, so a fifth source is an adapter rather than a second pipeline.

    • Raw lake

      Python

      Why

      Every payload is stored as fetched, under a versioned envelope, before anything reads it. The refiners can be rebuilt and re-run against it, and a determinism test proves the rebuild lands in the same place.

    • Spend log

      Python · DuckDB

      Why

      Paid sources are metered per pull. The rule above it is that a paid source is only reached for once a free one has been tried and written up as insufficient.

  2. Refinement

    • Per-source refiners

      Python · spaCy

      Why

      One per source, over a shared spine. Cleaning, noun-chunk extraction and sentiment happen here, and nothing at this layer invents a relationship between two entities.

    • Resample queue

      Python · DuckDB

  3. Analysis

    • Signals and graph

      Python

    • Content safety

      Python

  4. Substrate

    • Curated store

      DuckDB · Python

      Why

      Narrow tables and a thin signals layer on top of them. Every row carries the pull run that produced it, so any figure can be walked back to the fetch it came from.

  5. Surface

    • Product surface

      TypeScript · Next.js · NestJS · PostgreSQL

      Why

      A separate repository, forked from the SaaS substrate on this site, so it opens with tenancy, two-tier auth, audit and data rights already in place. What it adds is the reading surface: briefs in, cited answers out.

Every row remembers the fetch it came fromPythonFrom Curated store
"""Migration refinement:0013 — per-pull-run provenance on entity tables (ADR-0029).Adds write-once `pull_run_id` (the pull run that produced the row, from the rawdoc) + `first_refined_at` (net-new marker; backfilled = refined_at for existingrows) to the 6 single-PK entity tables. Composite-PK/snapshot/trends tables areout of scope (Phase 1b-ii). steam_apps already has first_refined_at (m_0010)."""from __future__ import annotationsimport duckdbID = "0013_curated_provenance"# (table, needs_first_refined_at) — steam_apps already has it (m_0010);# steam_reviews does NOT have it (confirmed: m_0004_steam has no first_refined_at# on steam_reviews, and no subsequent migration adds it before m_0013)._TABLES = [    ("reddit_submissions", True),    ("reddit_comments", True),    ("hn_stories", True),    ("hn_comments", True),    ("steam_apps", False),    ("steam_reviews", True),]def up(con: duckdb.DuckDBPyConnection) -> None:    # Ordered in three passes within the migration transaction. DuckDB rejects    # CREATE INDEX while there are outstanding row UPDATEs in the same    # transaction, so ALL index creation must happen BEFORE the backfill UPDATEs    # (ADD COLUMN is DDL and does not count as an outstanding update). On a    # populated DB the naive add->update->index ordering raises    # "Cannot create index with outstanding updates".    # Pass 1 — add columns (DDL).    for table, needs_fra in _TABLES:        con.execute(f"ALTER TABLE {table} ADD COLUMN pull_run_id VARCHAR")        if needs_fra:            con.execute(f"ALTER TABLE {table} ADD COLUMN first_refined_at TIMESTAMPTZ")    # Pass 2 — create indexes (before any UPDATE; built on the fresh NULL columns).    for table, _ in _TABLES:        con.execute(            f"CREATE INDEX IF NOT EXISTS {table}_first_refined_idx ON {table}(first_refined_at)"        )        con.execute(f"CREATE INDEX IF NOT EXISTS {table}_pull_run_idx ON {table}(pull_run_id)")    # Pass 3 — backfill first_refined_at for existing rows (UPDATEs last).    for table, needs_fra in _TABLES:        if needs_fra:            con.execute(                f"UPDATE {table} SET first_refined_at = refined_at WHERE first_refined_at IS NULL"            )
Adding write-once provenance to six entity tables, so any figure the product shows can be walked back to the pull run that produced it. The interesting part is the ordering. DuckDB refuses to create an index while a row update is outstanding in the same transaction, so the obvious sequence of add, backfill, index fails on a populated database, and the migration runs in three passes with every index built before the first UPDATE. The comment records the error rather than the workaround, which is the part a reader needs.

idea-codex430b9e9src/market_research/contexts/refinement/adapters/migrations/m_0013_curated_provenance.pyLines 1 to 5151 lines

"""Migration refinement:0013 — per-pull-run provenance on entity tables (ADR-0029).

Adds write-once `pull_run_id` (the pull run that produced the row, from the raw
doc) + `first_refined_at` (net-new marker; backfilled = refined_at for existing
rows) to the 6 single-PK entity tables. Composite-PK/snapshot/trends tables are
out of scope (Phase 1b-ii). steam_apps already has first_refined_at (m_0010).
"""

from __future__ import annotations

import duckdb

ID = "0013_curated_provenance"

# (table, needs_first_refined_at) — steam_apps already has it (m_0010);
# steam_reviews does NOT have it (confirmed: m_0004_steam has no first_refined_at
# on steam_reviews, and no subsequent migration adds it before m_0013).
_TABLES = [
    ("reddit_submissions", True),
    ("reddit_comments", True),
    ("hn_stories", True),
    ("hn_comments", True),
    ("steam_apps", False),
    ("steam_reviews", True),
]


def up(con: duckdb.DuckDBPyConnection) -> None:
    # Ordered in three passes within the migration transaction. DuckDB rejects
    # CREATE INDEX while there are outstanding row UPDATEs in the same
    # transaction, so ALL index creation must happen BEFORE the backfill UPDATEs
    # (ADD COLUMN is DDL and does not count as an outstanding update). On a
    # populated DB the naive add->update->index ordering raises
    # "Cannot create index with outstanding updates".
    # Pass 1 — add columns (DDL).
    for table, needs_fra in _TABLES:
        con.execute(f"ALTER TABLE {table} ADD COLUMN pull_run_id VARCHAR")
        if needs_fra:
            con.execute(f"ALTER TABLE {table} ADD COLUMN first_refined_at TIMESTAMPTZ")
    # Pass 2 — create indexes (before any UPDATE; built on the fresh NULL columns).
    for table, _ in _TABLES:
        con.execute(
            f"CREATE INDEX IF NOT EXISTS {table}_first_refined_idx ON {table}(first_refined_at)"
        )
        con.execute(f"CREATE INDEX IF NOT EXISTS {table}_pull_run_idx ON {table}(pull_run_id)")
    # Pass 3 — backfill first_refined_at for existing rows (UPDATEs last).
    for table, needs_fra in _TABLES:
        if needs_fra:
            con.execute(
                f"UPDATE {table} SET first_refined_at = refined_at WHERE first_refined_at IS NULL"
            )
  • Backwards, and on purpose. What gets pulled again is decided from what is already curated, so a thing nobody is reading falls out of the rotation and a thing moving fast gets sampled more often. A fixed schedule would spend the same money on both.

Lookup or compute

The product the engine feeds is a curated database with an analyst sitting on it. A question either has an answer already in the substrate, in which case it is a lookup, or it does not, in which case the pipeline computes one and the substrate keeps it. The second path is what stops the database going stale, and it is why collection is score-driven rather than scheduled.

The surface is its own repository, forked from the SaaS substrate, so it starts with tenancy, two-tier auth, audit and data rights and adds the part that is specific to this product: briefs in, cited answers out. The two sides meet at a written contract rather than at each other’s internals, which is what lets the engine keep changing shape underneath.

Contribution

Commits authored
420
Decisions recorded
38

4 of 6 weeks active · 4 May 2026 – 14 Jun 2026

Longest run · 2 weeks · May – May

The engine only. The surface repository shares its history with the substrate, and that work is counted there.

Measured 2026-09-09