Skip to content

Extraction runs: proposition lineage - #101

Merged
jimador merged 8 commits into
jimador/feat/extraction-run-storefrom
jimador/feat/extraction-run-lineage
Sep 10, 2026
Merged

jimador merged 8 commits into
jimador/feat/extraction-run-storefrom
jimador/feat/extraction-run-lineage

Conversation

@jimador

@jimador jimador commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Slice 9 of the extraction-integrity train (#67), stacked on #99. A stored claim can now be traced to the run that produced it. PropositionStore.saveAllReturningCanonical and PersistablePropositions.persistReturningCanonical are additive default-bodied calls returning PropositionPersistenceResult — the canonical proposition for each input, the input-id to stored-id mapping, and a distinct-by-canonical-id view; the result type rejects length mismatches, one input landing on two ids, per-position tenant mismatches, and one canonical id answered under two contexts. PropositionRunLinkStore (in-memory and Drivine, behind a 14-case cross-backend contract suite) writes a MERGE-idempotent (:Proposition)-[:PRODUCED_BY_RUN]->(:ExtractionRun) edge, tenant-guarded on both endpoints, with all-or-nothing batch writes proven inside the TOCTOU window. The headline invariant on Neo4j: two identical-content runs leave one proposition, one source grounding, and two run links. Lineage is recorded immediately after the claims are saved, before any fallible pass, so a throwing projector or grounding service leaves saved claims attributed and still propagates.

Changed in this review round:

  • Canonical persistence is the only path. persistAndProject used to take the repository's canonical propositions only when a run was present and the pre-save minted ones otherwise, which let an audit setting decide whether the graph was written correctly. It now wires projection, grounding and structural relationships against what save returned on every call; a run's only effect is the lineage write. A mutation restoring the old no-run wiring fails three tests, projection-targets-a-pre-save-id among them.
  • Attribution fails loud. A run present with no way to record its lineage is an error by policy; duplicate PRODUCED_BY_RUN edges cannot occur (MERGE on the pair, proven by the linking-twice case).
  • ExtractionRun.invocations normalize to plan order at construction (the Extraction runs: the Drivine store #99 follow-up); stored fingerprints are unaffected.
  • The experimental extraction surfaces are marked as such in the CHANGELOG.

Breaking changes: none. Every existing public signature, descriptor and default-body behavior is byte-identical, pinned by a binary-compatibility test asserting the @JvmOverloads synthetic constructor descriptors (arities 9–16) survived. One behavioral change, sanctioned and on an unreleased experimental surface: the no-run extraction path now also persists, projects and grounds against canonical ids — the audit-metadata-never-changes-product-behavior rule, applied.

Opt-in and status: EXPERIMENTAL, marked @ApiStatus.Experimental in code. The Drivine stores and the run schema DDL sit behind embabel.dice.extraction.runs.enabled (default false) alongside the graph backend condition; a host that never sets it gets no run beans and no constraints or indexes written, and the CHANGELOG names exactly what turning it on writes. Run attribution happens when ExtractionRequest.currentRun is set and a PropositionRunLinkStore is bound; with neither, behavior is the canonical path alone. Honest limit, in shipped docs: lineage is best-effort for application-level failures only — a statement failing at the server inside a wrapping host transaction terminates that transaction beneath Spring, demonstrated by a fault-injection test; the structural fix (committing claims before recording lineage) belongs to the coordinator slice and the docs name it.

The lineage shape. The root ref is denormalized onto every run, so a whole-lineage lookup is one indexed read, with no chain walk:

flowchart LR
    R[run A, pass 0, root = self] --> B[run B, pass 1, parent = A]
    B --> C[run C, pass 2, parent = B]
    B -. "root = A" .-> R
    C -. "root = A" .-> R
    PA[proposition] -->|PRODUCED_BY_RUN| C
    PB[proposition] -->|PRODUCED_BY_RUN| C
Loading

Chain walks stay bounded and cycle-safe, because the denormalized root is a read optimization and never the integrity check.

Landing

  • Build the union of this stack's tip and the metamodel stack's tip (feat(dice-storage-autoconfigure): opt-in metamodel wiring #88) before either lands; the two share six recurring conflict files with recorded resolutions; DiceStorageAutoConfiguration.kt conflicts too and is resolved by hand from the union worktree's committed adaptation, which also carries the runs gate on both schema beans.
  • Whichever stack lands second restacks and applies one adaptation: ExtractionRunSchema joins the metamodel stack's DiceStorageSchema derived-ownership contract (object declaration, override on specs(), bookkeeping relationship types, catalog and test beans). DiceStorageSchemaRegistrationTest fails until it is applied — that is the guard working.
  • No consumer migration is required to land: the four-argument undoSingleCollapse stays as a deprecated overload (Source-revision provenance: authoritative collector fold and undo #92). Downstream callers move to CollapseUndoCommand when convenient.

@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 9c34597 to 850c318 Compare September 1, 2026 04:20
@jimador
jimador marked this pull request as ready for review September 1, 2026 04:20
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 850c318 to 01efb62 Compare September 1, 2026 14:07
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 01efb62 to daed48f Compare September 2, 2026 10:51
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from daed48f to eeed0b7 Compare September 2, 2026 14:11
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from eeed0b7 to 661845b Compare September 2, 2026 15:07
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 661845b to 87a6a54 Compare September 2, 2026 15:24
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 87a6a54 to aa19efa Compare September 2, 2026 20:35
@jimador
jimador requested a review from igordayen September 2, 2026 20:41
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from aa19efa to 47dbc6f Compare September 2, 2026 20:50
Comment thread docs/design/extraction-runs.md
Comment thread CHANGELOG.md
Comment thread CHANGELOG.md
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from e127de0 to 4e9d478 Compare September 8, 2026 21:14
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch 2 times, most recently from 01304dc to ec4416b Compare September 9, 2026 01:13
@igordayen

Copy link
Copy Markdown

Findings from CODEX:

Findings

  1. STRICT lineage does not fail the async event path.
    IncrementalPropositionExtraction.kt:472-476 (https://github.com/embabel/dice/pull/101/files#diff-1) catches every exception from
    persistAndProject, including LineageNotRecordedException, and only logs it. But SourceAnalysisRequestEvent.kt:67-77
    (https://github.com/embabel/dice/pull/101/files#diff-2) now lets async publishers attach currentRun(). Example: an event carries
    currentRun, no PropositionRunLinkStore is bound, STRICT throws, then the listener swallows it. The caller/coordinator sees no
    failure signal, so “strict attribution fails the operation” is only true for direct calls, not for the async path added here.

  2. The current ordering can leave saved claims unattributed when projection/grounding fails.
    IncrementalPropositionExtraction.kt:611-647 (https://github.com/embabel/dice/pull/101/files#diff-3) saves canonical propositions,
    then structural wiring, projection, grounding, and only then writes lineage. So if projection throws after the save, the
    proposition is stored but no PRODUCED_BY_RUN edge is attempted. Example: run R1 extracts proposition P1; save succeeds; projector
    throws; P1 remains in the store, but propositionsOf(R1) is empty. This contradicts the PR body and
    PersistablePropositions.kt:120-125 (https://github.com/embabel/dice/pull/101/files#diff-4), which say the split exists so saved
    claims are not stranded without the run that produced them. If lineage-last is intentional, those statements need to be corrected;
    if traceability is the invariant, lineage needs to happen immediately after canonical save.

  3. InMemoryPropositionRunLinkStore retains stale links forever.
    InMemoryPropositionRunLinkStore.kt:57-58 (https://github.com/embabel/dice/pull/101/files#diff-5) keeps every run-to-proposition id
    in byRun, and lines 108-110 (https://github.com/embabel/dice/pull/101/files#diff-5) explicitly leave deleted proposition ids in
    the map. Reads filter stale ids, but memory still grows with every extraction/run/proposition ever linked. For a long-running
    in-memory host or tests that reuse the store, deleted or expired propositions disappear from API results but remain retained
    internally. Consider opportunistic pruning on reads, a clear/delete hook, or making this explicitly test/dev-only.

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - would you please review some findings from Codex, concern with stale memory, thank you

@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from ec4416b to af3ff70 Compare September 9, 2026 21:57
@jimador

jimador commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

All three addressed.

  1. Lineage now runs right after the canonical save, before structural wiring, projection and grounding, so a claim is attributed the moment it exists and a throwing projector cannot strand it. The KDoc and the design doc describe the state a STRICT failure leaves and no longer argue for lineage-last. d3cbee7.
  2. The async path honours STRICT: a LineageNotRecordedException is logged at error and rethrown to the publisher, and the queue keeps draining so one failing event does not drop the next. f8eb4f7.
  3. The in-memory link store prunes an id on read when the proposition no longer exists at all, and drops an empty run entry; a read from the wrong tenant still fails closed and touches nothing. af3ff70.

@jimador
jimador requested a review from igordayen September 9, 2026 21:58
@igordayen

Copy link
Copy Markdown

3. The in-memory link store prunes an id on read when the proposition no longer exists at all, and drops an empty run entry; a read from the wrong tenant still fails closed and touches nothing. af3ff7

@jimador - would an eviction policy be useful for this use case? Thank you

@igordayen igordayen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - thanks for addressing inquiries, looks good. thanks

@igordayen

Copy link
Copy Markdown

@jimador - looks like rebase is required, thx

@jimador

jimador commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Yes, and it is in ff8e343. Pruning on read only helps a run somebody reads, so InMemoryPropositionRunLinkStore now takes maxRuns (default 10,000, same as the reference run store) and past it drops the links of the runs it linked earliest once the run store says they have ended. A run still RUNNING keeps its links, and a breach with nothing to evict logs once. Design doc and changelog updated.

On the rebase: the branch is on current main, GitHub reports it clean. What it is showing is the stacked base, since #101 sits on #99, which sits on #98 and so on down to #91. It merges after those, and the stack tool moves each remaining branch onto main as the one below it lands.

@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from ff8e343 to e506941 Compare September 10, 2026 02:02
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from e506941 to 8fa72d8 Compare September 10, 2026 02:10
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Canonical persistence is now the only path: projection and grounding
wire against what save returned whether or not a run is set, closing
the split where the no-run path kept pre-save minted ids, and a run's
only effect is the lineage write. That write runs last, so a STRICT
failure leaves claims persisted, edges wired, projection and grounding
complete, and no PRODUCED_BY_RUN — the operation reports failure and
nothing claims a run produced the result. STRICT is the default where a
run is set; LENIENT records the failure and continues, and both end
states are tabled in the design doc. Duplicate links cannot form: the
in-memory store guards on the pair and the Drivine store merges the
edge, which Neo4j serialises on its endpoints. The run and link stores
join the autoconfiguration under the graph condition, overridable and
inert until a caller sets a run.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The revision capability, run model, failure vocabulary, request object
and protected-content specification entries now carry the EXPERIMENTAL
marker and name their opt-in triggers.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Registering the Drivine run store, the link store and the run schema
under the graph condition alone wrote three constraints and five
indexes into every graph-backed host on its next snapshot, for a
feature nothing there calls. The three beans now sit behind
embabel.dice.extraction.runs.enabled, default false, and the changelog
says exactly what enabling writes to the database.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
recordRunLineage used to run last in persistAndProject, after structural
wiring, projection and grounding had all completed. A projector or wirer
that threw left the saved claims with no PRODUCED_BY_RUN edge, even
though PersistablePropositions.persistCanonicalPropositions was split
out specifically so a failing edge write could not strand a stored claim
without a record of the run that produced it.

Move recordRunLineage to right after persistCanonicalPropositions
returns, before wireStructuralRelationships. A stored claim is now
attributed the moment it exists. A STRICT lineage failure now leaves the
claim saved and unattributed with the later passes never run; it used to
leave a fully wired extraction missing only the audit edge.

Rewrite the KDoc on persistAndProject and recordRunLineage to describe
the new ordering and end state, invert RunLineageWiringTest's
structural-wiring test to assert the claim is attributed before the
failing pass runs, add the matching case for a throwing projector, and
update the two lineage-failure end-state tests for the new declared
state. Update docs/design/extraction-runs.md and the CHANGELOG entry for
PR #101 to match.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
extractPropositions caught every exception from an event-published
extraction and logged it at warn, so a LineageNotRecordedException under
STRICT was lost on the async path while the same inputs on a direct call
failed. It is now logged at error with the run key and rethrown, and the
queue drain keeps going past a failing event and raises the first failure
once the queue it can see is empty, so one wiring mistake costs no other
event its turn. The policy KDoc, the design note and the changelog say
what a host sees on each path.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The in-memory link store is never told when a proposition is deleted, so
a run kept naming ids that no longer resolved and the map only grew.
Both reads now drop an id the proposition store no longer holds at all,
and drop a run left with no links. A read from the wrong tenant still
fails closed and touches nothing.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Pruning on read only helps a run somebody reads. The in-memory link
store now takes maxRuns, defaulting to 10,000 like the reference run
store, and past it evicts the links of the runs linked earliest once
the run store says they have ended. A run still running keeps its
links, and a breach with nothing to evict logs once.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
@jimador
jimador force-pushed the jimador/feat/extraction-run-lineage branch from 8fa72d8 to dbcb3ac Compare September 10, 2026 02:17
@jimador
jimador merged commit 0dc362d into main Sep 10, 2026
16 checks passed
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.

3 participants