Implement the group constraint propagation engine #19

Open
opened 2026-07-28 04:55:04 +00:00 by claude-bot · 2 comments

Context

This is the central idea of the product. Most photos in a family collection carry no
date of their own, but they sit between photos that do. If photo 12 is from June 1987
and photo 20 is from September 1987, photos 13-19 are bounded by those dates. The
schema is already built for it: EvidenceSource.constraint, EvidenceReliability.derived,
DateEvidence.derived_from, and JobType.constraint_rebuild all exist and are unused.

Scope

The engine that derives date constraints from album ordering and known anchors, and
writes them as derived evidence.

Implementation notes

  • Inputs: album ordering, approved decisions on neighbouring photos, event anchors, and
    format rules (a print format that only existed in a given period).
  • For each undated photo, walk outward to the nearest bounding approved dates and derive
    a range. Write it as DateEvidence with source = constraint, reliability = derived,
    and derived_from pointing at the evidence it was inferred from — the provenance chain
    is what makes the explanation UI possible, so it must be populated correctly.
  • Derived evidence must never outrank human decisions or hard evidence. A reviewer's
    approval always wins. Derived constraints are a suggestion, never an auto-approval.
  • Recompute via the constraint_rebuild job when an ordering changes, a decision is made,
    or an anchor moves. Recomputation must be incremental where possible — rebuilding the
    whole collection on every single decision will not scale.
  • Superseding, not deleting: when a constraint is recomputed, the old derived row is
    superseded like any other evidence. History stays intact.
  • Contradictions are expected and must be handled explicitly: if bounding dates are
    inconsistent (a later photo dated earlier than an earlier one), surface the conflict
    for human resolution rather than silently picking one. Guard against propagating a
    contradiction outward through the chain.
  • Cycles in the derivation graph must be impossible — derived evidence must not become
    input to its own recomputation.

Done when

  • Undated photos between two approved dates receive a bounded derived range
  • Every derived row records its provenance via derived_from
  • Derived evidence never overrides a human decision or hard evidence
  • Recomputation is incremental and triggered by the right events
  • Contradictory constraints surface as conflicts rather than silent choices
  • No derivation cycles are possible

References

  • backend/app/models/models.py (EvidenceSource.constraint, EvidenceReliability.derived, derived_from, JobType.constraint_rebuild)
  • docs/circa-spec.md (constraint propagation)

Depends on: album ordering, #2 (worker runtime).

## Context This is the central idea of the product. Most photos in a family collection carry no date of their own, but they sit between photos that do. If photo 12 is from June 1987 and photo 20 is from September 1987, photos 13-19 are bounded by those dates. The schema is already built for it: `EvidenceSource.constraint`, `EvidenceReliability.derived`, `DateEvidence.derived_from`, and `JobType.constraint_rebuild` all exist and are unused. ## Scope The engine that derives date constraints from album ordering and known anchors, and writes them as derived evidence. ## Implementation notes - Inputs: album ordering, approved decisions on neighbouring photos, event anchors, and format rules (a print format that only existed in a given period). - For each undated photo, walk outward to the nearest bounding approved dates and derive a range. Write it as `DateEvidence` with `source = constraint`, `reliability = derived`, and `derived_from` pointing at the evidence it was inferred from — the provenance chain is what makes the explanation UI possible, so it must be populated correctly. - **Derived evidence must never outrank human decisions or hard evidence.** A reviewer's approval always wins. Derived constraints are a suggestion, never an auto-approval. - Recompute via the `constraint_rebuild` job when an ordering changes, a decision is made, or an anchor moves. Recomputation must be incremental where possible — rebuilding the whole collection on every single decision will not scale. - Superseding, not deleting: when a constraint is recomputed, the old derived row is superseded like any other evidence. History stays intact. - Contradictions are expected and must be handled explicitly: if bounding dates are inconsistent (a later photo dated earlier than an earlier one), surface the conflict for human resolution rather than silently picking one. Guard against propagating a contradiction outward through the chain. - Cycles in the derivation graph must be impossible — derived evidence must not become input to its own recomputation. ## Done when - [ ] Undated photos between two approved dates receive a bounded derived range - [ ] Every derived row records its provenance via `derived_from` - [ ] Derived evidence never overrides a human decision or hard evidence - [ ] Recomputation is incremental and triggered by the right events - [ ] Contradictory constraints surface as conflicts rather than silent choices - [ ] No derivation cycles are possible ## References - `backend/app/models/models.py` (`EvidenceSource.constraint`, `EvidenceReliability.derived`, `derived_from`, `JobType.constraint_rebuild`) - `docs/circa-spec.md` (constraint propagation) Depends on: album ordering, #2 (worker runtime).
claude-bot added this to the v0.4.0 milestone 2026-07-28 04:55:04 +00:00
Author

Amended by the audit of 2026-07-28.

Three prerequisites this issue depends on that are not currently represented:

  1. The schema cannot express what this engine produces (#108). DateEvidence.derived_from is a
    single FK, but a constraint derives a range from two anchors ("between photo 12 and photo
    20"). #20's explanation UI depends on that chain being traversable. Fix before the evidence table
    holds tens of thousands of rows — on SQLite this is a table-rebuild migration.

  2. Album context has no ingestion path (#105). filename_parser.py extracts album_slug,
    sequence, and is_back, and ingest discards all three. Photo.album_id and
    sequence_in_album are never populated, and nothing in the codebase creates an Album. This
    engine would start from empty data.

  3. The evidence it will trust is currently poisoned (#74). Scanner EXIF dates are recorded as
    hard/day precision, and hard is exactly what this engine amplifies and propagates to
    neighbours.

Also relevant: PhotoGroup (spec §8) does not exist. This issue is titled "group constraint
propagation" but its body only uses albums and anchors — #109 creates the missing entity.

**Amended by the audit of 2026-07-28.** Three prerequisites this issue depends on that are not currently represented: 1. **The schema cannot express what this engine produces (#108).** `DateEvidence.derived_from` is a single FK, but a constraint derives a range from **two** anchors ("between photo 12 and photo 20"). #20's explanation UI depends on that chain being traversable. Fix before the evidence table holds tens of thousands of rows — on SQLite this is a table-rebuild migration. 2. **Album context has no ingestion path (#105).** `filename_parser.py` extracts `album_slug`, `sequence`, and `is_back`, and ingest **discards all three**. `Photo.album_id` and `sequence_in_album` are never populated, and nothing in the codebase creates an `Album`. This engine would start from empty data. 3. **The evidence it will trust is currently poisoned (#74).** Scanner EXIF dates are recorded as `hard`/`day` precision, and `hard` is exactly what this engine amplifies and propagates to neighbours. Also relevant: `PhotoGroup` (spec §8) does not exist. This issue is titled "group constraint propagation" but its body only uses albums and anchors — #109 creates the missing entity.
Author

Context from #144, which deliberately left this alone.

#144 wired ingest up to enqueue OCR and stopped there. docs/circa-spec.md §8.3 step 9 also asks ingest to enqueue constraint propagation — this issue — and that half is still unwired: JobType.constraint_rebuild remains unused, as the body says. Recording it so the gap is not rediscovered as a surprise rather than found in the spec.

The "depends on album ordering" line is now partly satisfied, and the distinction matters.

This issue's whole premise is that photo 12 is June 1987, photo 20 is September 1987, and 13–19 are bounded by them — which requires knowing that those photographs are in the same album, in that order. When this was written none of that existed: nothing in the codebase had ever created an Album row, and photo.album_id was NULL in every deployment.

Since then:

  • #140 files a scan into its album at ingest and sets sequence_in_album from the filename.
  • #141 added the albums endpoint and album-position sort, and Photo.album_sort — a generated column carrying a (album_id, sequence) keyset, with the NULL-sentinel handling already worked out.
  • #105 made a whole box importable at once, so an album is now populated in one operation rather than photograph by photograph.

So implicit ordering — album membership plus a filename-derived sequence — exists and is populated. That is enough to build a first constraint pass against.

#18 is still open, and it is the one that adds explicit ordering — a reviewer reordering an album by hand when the filenames are wrong or absent. So the dependency is satisfied for the common case and not for the general one. Worth deciding deliberately whether this waits for #18 or is built against the filename-derived sequence and refined later; both are defensible, and building first would surface what #18 actually needs to expose.

Two things to reuse rather than reinvent:

  • app/services/jobs.py::enqueue_photo_job is the single enqueue — attempt numbering, idempotency key, in-flight fast path, race recovery — called by both the rerun route and ingest. A third copy is what #105 paid for. Note its collision recovery is a savepoint rather than db.rollback(), so a queue collision cannot discard an in-flight caller's work.
  • The incremental-recomputation requirement in the body has a new trigger to account for: a fold (#105) writes a front onto a row that already existed as an orphan back, so a photograph can acquire its front — and therefore its place in the album — after the rows around it have already been dated and propagated over.

One caution the body already implies but is worth stating against the new ingest: sequence_in_album is parsed from a filename and is only as trustworthy as the naming. A gap in the sequence may be a missing page, or may be a scan whose name did not parse. Deriving a date range across a gap assumes the former, so this engine should not treat sequence adjacency as physical adjacency without saying so.

Context from #144, which deliberately left this alone. #144 wired ingest up to enqueue OCR and stopped there. `docs/circa-spec.md` §8.3 step 9 also asks ingest to enqueue **constraint propagation** — this issue — and that half is still unwired: `JobType.constraint_rebuild` remains unused, as the body says. Recording it so the gap is not rediscovered as a surprise rather than found in the spec. **The "depends on album ordering" line is now *partly* satisfied, and the distinction matters.** This issue's whole premise is that photo 12 is June 1987, photo 20 is September 1987, and 13–19 are bounded by them — which requires knowing that those photographs are *in the same album, in that order*. When this was written none of that existed: nothing in the codebase had ever created an `Album` row, and `photo.album_id` was NULL in every deployment. Since then: - **#140** files a scan into its album at ingest and sets `sequence_in_album` from the filename. - **#141** added the albums endpoint and album-position sort, and `Photo.album_sort` — a generated column carrying a `(album_id, sequence)` keyset, with the NULL-sentinel handling already worked out. - **#105** made a whole box importable at once, so an album is now populated in one operation rather than photograph by photograph. So **implicit ordering — album membership plus a filename-derived sequence — exists and is populated.** That is enough to build a first constraint pass against. **#18 is still open**, and it is the one that adds *explicit* ordering — a reviewer reordering an album by hand when the filenames are wrong or absent. So the dependency is satisfied for the common case and not for the general one. Worth deciding deliberately whether this waits for #18 or is built against the filename-derived sequence and refined later; both are defensible, and building first would surface what #18 actually needs to expose. **Two things to reuse rather than reinvent:** - `app/services/jobs.py::enqueue_photo_job` is the single enqueue — attempt numbering, idempotency key, in-flight fast path, race recovery — called by both the rerun route and ingest. A third copy is what #105 paid for. Note its collision recovery is a savepoint rather than `db.rollback()`, so a queue collision cannot discard an in-flight caller's work. - The incremental-recomputation requirement in the body has a new trigger to account for: a **fold** (#105) writes a front onto a row that already existed as an orphan back, so a photograph can acquire its front — and therefore its place in the album — after the rows around it have already been dated and propagated over. One caution the body already implies but is worth stating against the new ingest: `sequence_in_album` is parsed from a filename and is only as trustworthy as the naming. A gap in the sequence may be a missing page, or may be a scan whose name did not parse. Deriving a date range across a gap assumes the former, so this engine should not treat sequence adjacency as physical adjacency without saying so.
Sign in to join this conversation.
No description provided.