Skip to content

feat(kernel): ingest pipeline — hashing, blob dedup, asset origin, batch materialization (#20) - #91

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/ingest-pipeline
Jul 27, 2026
Merged

feat(kernel): ingest pipeline — hashing, blob dedup, asset origin, batch materialization (#20)#91
JArmandoAnaya merged 1 commit into
mainfrom
feat/ingest-pipeline

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #20.

IngestService is the one door that turns a registered source into rows: it hashes every item, stores the bytes once, records what the decoder made of them, and puts the result in a draft batch. It closes the last write in the kernel that had no service behind it — _add_assets is gone from examples/sdk_end_to_end.py, which now writes its frames to incoming/, registers the directory as a source and ingests it like a real caller would.

Acceptance criteria

  • Duplicate image across two sources ⇒ one blob — and one asset, asserted on the blob directory and on the rows.
  • Asset origin metadata correct for both directory and video pathssource_id on every asset, plus frame_index/frame_timestamp and a clip.mp4#frame=7 uri for a decomposed clip.
  • Pipeline covered end-to-end with generated fixtures — 51 new tests, nothing reads a checked-in binary.

Design

Migration 8 — FORMAT_VERSION is now 8

Adds asset.format / source_id / frame_index / frame_timestamp, links ingest_job to its batch, and puts indexes under the two uniqueness rules that had been running as service-level pre-checks with nothing beneath them:

Two findings the fresh-versus-migrated test caught while writing it, both now documented:

  • SQLAlchemy cannot reflect an expression-based index, so checkfirst reports it absent and re-issues the CREATE. That one index uses CREATE UNIQUE INDEX IF NOT EXISTS, still compiled from the shared Index object.
  • A column carrying a foreign key cannot arrive by ALTER — SQLite spells an added key inline while create_all spells it as a table constraint, so the two paths emit different DDL. ingest_job is rebuilt (no children, provably empty, counted) so batch_id keeps a real key; asset.source_id is added without one, because asset has four cascading children and rows that were legitimately already there.

Duplicate pre-checks refuse a workspace either index could not accept, naming both counts, rather than letting an IntegrityError escape initialize().

Also

  • IngestCompleted has its first emitter, flipping the tripwire tests/kernel/test_events.py has held since kernel: in-process EventBus + domain events (BatchApproved, ReleasePublished, IngestCompleted) #13 — narrowed there rather than deleted, so the annotation cycle still cannot quietly claim to be an ingest.
  • BatchService._require_draft promoted to public require_draft(uow, batch_id), so a frozen target batch is refused before anything is decoded.
  • New docs/ingest.md; sources.md, persistence.md, events.md, examples.md and media.md updated where they described this as future work.

Checks

uv run ruff check .            All checks passed!
uv run ruff format --check .   113 files already formatted
uv run mypy src/visionset/kernel   Success: no issues found in 50 source files
uv run mypy src/visionset          Success: no issues found in 60 source files
uv run lint-imports            Contracts: 2 kept, 0 broken.
uv run pytest                  828 passed
uv run python examples/sdk_end_to_end.py   green
uv run python scripts/export_openapi.py    no diff

VERSION stays 0.0.1.dev0; no new dependency.

…tch materialization (#20)

`IngestService` is the one door that turns a registered source into rows: it
hashes every item, stores the bytes once, records what the decoder made of them,
and puts the result in a draft batch. It closes the last write in the kernel that
had no service behind it — `_add_assets` is gone from the SDK example, which now
registers a directory and ingests it like a real caller.

- One `ingest(source_id, *, batch_id=None, batch_name=None)`, branching on
  `SourceKind`: registration needed two methods because its arguments differed,
  this does not — the source already carries the kind, the path and the rate.
- Identity is content, origin is provenance. The same bytes in two sources are
  one blob and one asset, and the asset keeps the origin of the first sighting.
- Four transactions with the decode outside all of them, and blobs written
  before any row: an out-of-process decoder inside a write transaction is how a
  single-writer SQLite store starts reporting "database is locked".
- Failure splits by remedy: unsupported and corrupt items are reported per file
  and the run carries on; a missing ffmpeg fails the job and is re-raised.
- First emitter of `IngestCompleted`, flipping the tripwire held since #13.

Migration 8 (`FORMAT_VERSION` 8) gives `asset` its format and origin, links a run
to its batch, and adds the two unique indexes both rules had been running
without — `uq_asset_project_content_hash`, and the expression index
`uq_source_project_kind_path_fps` that #18 named as owed. `asset` is altered
rather than rebuilt (four cascading children, and pre-existing rows that were
legitimate); `ingest_job` is rebuilt so its new key can match `create_all`.
@JArmandoAnaya
JArmandoAnaya merged commit 1c4ebd3 into main Jul 27, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/ingest-pipeline branch July 27, 2026 11:51
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…tch materialization (#20) (#91)

`IngestService` is the one door that turns a registered source into rows: it
hashes every item, stores the bytes once, records what the decoder made of them,
and puts the result in a draft batch. It closes the last write in the kernel that
had no service behind it — `_add_assets` is gone from the SDK example, which now
registers a directory and ingests it like a real caller.

- One `ingest(source_id, *, batch_id=None, batch_name=None)`, branching on
  `SourceKind`: registration needed two methods because its arguments differed,
  this does not — the source already carries the kind, the path and the rate.
- Identity is content, origin is provenance. The same bytes in two sources are
  one blob and one asset, and the asset keeps the origin of the first sighting.
- Four transactions with the decode outside all of them, and blobs written
  before any row: an out-of-process decoder inside a write transaction is how a
  single-writer SQLite store starts reporting "database is locked".
- Failure splits by remedy: unsupported and corrupt items are reported per file
  and the run carries on; a missing ffmpeg fails the job and is re-raised.
- First emitter of `IngestCompleted`, flipping the tripwire held since #13.

Migration 8 (`FORMAT_VERSION` 8) gives `asset` its format and origin, links a run
to its batch, and adds the two unique indexes both rules had been running
without — `uq_asset_project_content_hash`, and the expression index
`uq_source_project_kind_path_fps` that #18 named as owed. `asset` is altered
rather than rebuilt (four cascading children, and pre-existing rows that were
legitimate); `ingest_job` is rebuilt so its new key can match `create_all`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kernel: ingest pipeline — SHA-256 hashing, blob-store dedup, technical metadata extraction, materialization into a Batch

1 participant