Skip to content

Commit 6f79256

Browse files
memory: an imported retraction stays retracted on the LadybugDB backend (#123)
Closes #110, taking option 3 of the three the issue costed — persist `superseded_by` as a column and keep the edge derived from it. `superseded_by` was reconstructed from the SUPERSEDED_BY edge, and `add()` cannot create an edge towards a claim the store does not have yet. In the natural import order it does not have it: `all_claims()` returns oldest-first, so a superseded claim arrives before the claim that superseded it. The edge was skipped while `superseded_at` was written, so the same row said "retracted" and `superseded_by IS NULL` said "current" — `Claim.is_current` believed the second, and `current("svc")` returned both sides of a correction. Two of three backends agreed; this one contradicted itself. `supersede()` was never affected because it writes the edge itself, which is why every test that went through it passed. The column is what reads project now, and `current()` filters on it rather than on an OPTIONAL MATCH, which is both tidier and the actual fix — filtering on the edge is what returned a retracted claim as live. The edge remains, because it is the provenance chain this backend exists for and `cypher()` walks it. `_write` reconciles it in both directions on every write: forward when this claim names a superseder that is present, backward when a stored claim names *this* one and could not have an edge until now. So the edge is complete once both ends have arrived, in either order, and a stale one is dropped first — `add` is an upsert, for the same reason the entity edges are rebuilt rather than merged. Option 1 (fail closed) was rejected because it breaks replaying `all_claims()` in its own returned order, and option 2 (a stub node) because the stub reads back as a ValidationError and has no `seq`, which is what `test_add_is_an_upsert_that_keeps_insertion_order` is about. The cost option 3 was costed at is the schema change, and it is handled rather than assumed: `CREATE NODE TABLE IF NOT EXISTS` does not alter an existing table, so `_migrate_superseded_by` adds the column and backfills it from the edges an older database does have. Verified against a database built with the driver in the shape the old code created, not a checked-in fixture: the link is recovered, `seq` does not restart, and a second open is a no-op. A driver that cannot add the column raises with an explanation instead of writing rows that are wrong. The module docstring said `superseded_by` is "an *edge* rather than a foreign key in a column". It is both now, and says so. Verified: 2197 selected, 13 deselected, ruff clean. Five of the eight new tests are red without the fix; the other three guard the mechanism it adds. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e928214 commit 6f79256

3 files changed

Lines changed: 278 additions & 22 deletions

File tree

‎docs/deep-dive.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
254254
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
255255
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
256256

257-
**Verified this pass:** `pytest` → green, 2,190 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
257+
**Verified this pass:** `pytest` → green, 2,197 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
258258

259259
[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.
260260

‎grapharc/memory/ladybug_store.py‎

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
LadybugDB is an embedded property-graph database with Cypher — a fork of Kuzu,
99
revived in 2025 after Apple acquired and closed it. Embedded means the same
1010
deal SQLite offers: a path on disk, no server, no daemon to run. What it adds
11-
over the SQLite backend is that `superseded_by` is an *edge* rather than a
12-
foreign key in a column, and the subject and object of every claim are `Entity`
13-
nodes, so "what did run #12 believe that run #37 corrected" is a path, and you
14-
can ask it in Cypher without going through Python at all::
11+
over the SQLite backend is that `superseded_by` is an *edge* as well as a
12+
column, and the subject and object of every claim are `Entity` nodes, so "what
13+
did run #12 believe that run #37 corrected" is a path, and you can ask it in
14+
Cypher without going through Python at all::
1515
1616
store = LadybugMemoryStore("memory.lbdb")
1717
store.cypher(
@@ -72,6 +72,7 @@
7272
run_id STRING,
7373
confidence DOUBLE,
7474
superseded_at STRING,
75+
superseded_by STRING,
7576
subject_norm STRING,
7677
predicate_norm STRING,
7778
seq INT64
@@ -96,14 +97,26 @@
9697
"CREATE REL TABLE IF NOT EXISTS SUPERSEDED_BY(FROM Claim TO Claim)",
9798
)
9899

99-
# `superseded_by` is not a column — it is reconstructed from the edge, so every
100-
# read pairs its MATCH with this OPTIONAL MATCH and this projection.
100+
# `superseded_by` is stored as a column *and* walkable as an edge, and the
101+
# column is the one reads project. It was edge-only, reconstructed by an
102+
# OPTIONAL MATCH on every read, which reads well and loses data: `add()` cannot
103+
# create an edge to a claim the store does not have yet, and in the natural
104+
# import order it does not have it — `all_claims()` returns oldest-first, so a
105+
# superseded claim arrives before the claim that superseded it. The edge was
106+
# silently skipped while `superseded_at` was written, so the row said "retracted"
107+
# and `superseded_by IS NULL` said "current", and `current()` returned both
108+
# sides of a correction (issue #110).
109+
#
110+
# The edge is still what you walk in Cypher — it is the provenance chain this
111+
# backend exists for, and `_write` reconciles it in both directions so it is
112+
# complete once both ends have arrived, whatever order they arrived in. What
113+
# changed is which of the two survives an import that has only seen one end.
101114
_OPTIONAL_SUPERSEDER = "OPTIONAL MATCH (c)-[:SUPERSEDED_BY]->(n:Claim)"
102115
_PROJECTION = """
103116
c.id AS id, c.subject AS subject, c.predicate AS predicate, c.object AS object,
104117
c.source AS source, c.observed_at AS observed_at, c.run_id AS run_id,
105118
c.confidence AS confidence, c.superseded_at AS superseded_at,
106-
n.id AS superseded_by
119+
c.superseded_by AS superseded_by
107120
"""
108121

109122
_UPSERT = """
@@ -112,11 +125,13 @@
112125
c.subject=$subject, c.predicate=$predicate, c.object=$object,
113126
c.source=$source, c.observed_at=$observed_at, c.run_id=$run_id,
114127
c.confidence=$confidence, c.superseded_at=$superseded_at,
128+
c.superseded_by=$superseded_by,
115129
c.subject_norm=$subject_norm, c.predicate_norm=$predicate_norm, c.seq=$seq
116130
ON MATCH SET
117131
c.subject=$subject, c.predicate=$predicate, c.object=$object,
118132
c.source=$source, c.observed_at=$observed_at, c.run_id=$run_id,
119133
c.confidence=$confidence, c.superseded_at=$superseded_at,
134+
c.superseded_by=$superseded_by,
120135
c.subject_norm=$subject_norm, c.predicate_norm=$predicate_norm
121136
"""
122137

@@ -164,6 +179,7 @@ def _params(claim: Claim, seq: int) -> dict[str, Any]:
164179
"run_id": claim.run_id,
165180
"confidence": float(claim.confidence),
166181
"superseded_at": claim.superseded_at,
182+
"superseded_by": claim.superseded_by,
167183
"subject_norm": _normalize(claim.subject),
168184
"predicate_norm": _normalize(claim.predicate),
169185
"seq": seq,
@@ -200,8 +216,47 @@ def __init__(
200216
if not read_only:
201217
for statement in _SCHEMA:
202218
self._conn.execute(statement)
219+
self._migrate_superseded_by()
203220
self._seq = self._next_seq()
204221

222+
def _migrate_superseded_by(self) -> None:
223+
"""Add the `superseded_by` column to a database that predates it.
224+
225+
`CREATE NODE TABLE IF NOT EXISTS` does not alter an existing table, so a
226+
database written before #110 has every other column and not this one.
227+
Adding it leaves the column NULL on every row, which would read as "no
228+
claim was ever superseded" — so the existing edges are the thing to
229+
trust here, and the backfill copies them into the column. Those edges
230+
were written by `supersede()`, which always created them; it is the
231+
`add()` path that never did, and that path left nothing to recover.
232+
233+
`ALTER TABLE` raises on a database that already has the column, which is
234+
every database created since. That is the expected outcome, not an
235+
error, so it is swallowed — narrowly, by re-reading the schema
236+
afterwards rather than by assuming.
237+
"""
238+
try:
239+
self._conn.execute("ALTER TABLE Claim ADD superseded_by STRING")
240+
except Exception:
241+
# Either the column is already there (the common case) or the
242+
# driver rejected the statement. The projection below decides which.
243+
pass
244+
try:
245+
self._conn.execute("MATCH (c:Claim) RETURN c.superseded_by LIMIT 1").get_all()
246+
except Exception as exc: # pragma: no cover - a driver too old to alter
247+
raise RuntimeError(
248+
"this LadybugDB database has no `superseded_by` column and it "
249+
"could not be added, so a superseded claim cannot be stored "
250+
"correctly (see issue #110). Re-create the store from "
251+
f"`all_claims()` of a copy, or use SQLiteMemoryStore. Cause: {exc}"
252+
) from exc
253+
# Backfill from the edges, which are the only record an older database
254+
# has. Idempotent: re-running sets the same ids.
255+
self._conn.execute(
256+
"MATCH (c:Claim)-[:SUPERSEDED_BY]->(n:Claim) "
257+
"WHERE c.superseded_by IS NULL SET c.superseded_by = n.id"
258+
)
259+
205260
def _next_seq(self) -> int:
206261
"""Resume the insertion counter where the last process left it."""
207262
rows = self._conn.execute("MATCH (c:Claim) RETURN max(c.seq)").get_all()
@@ -239,10 +294,7 @@ def _transaction(self) -> Iterator[None]:
239294
self._conn.execute("COMMIT")
240295

241296
def _query(self, where: str, params: dict[str, Any], order: str = "c.seq") -> list[Claim]:
242-
cypher = (
243-
f"MATCH (c:Claim) {where} {_OPTIONAL_SUPERSEDER} "
244-
f"RETURN {_PROJECTION} ORDER BY {order}"
245-
)
297+
cypher = f"MATCH (c:Claim) {where} RETURN {_PROJECTION} ORDER BY {order}"
246298
with self._lock:
247299
result = self._conn.execute(cypher, params)
248300
return [_to_claim(row) for row in result.rows_as_dict()]
@@ -275,6 +327,40 @@ def _write(self, claim: Claim) -> None:
275327
f"MERGE (c)-[:{rel}]->(e)",
276328
{"id": claim.id, "name": name},
277329
)
330+
self._reconcile_supersession(claim.id)
331+
332+
def _reconcile_supersession(self, claim_id: str) -> None:
333+
"""Make the SUPERSEDED_BY edges agree with the columns, both ways round.
334+
335+
Called for every write, because a claim can arrive at either end of a
336+
correction first and the edge needs both ends to exist:
337+
338+
- *forward*: this claim names a superseder. If that claim is present the
339+
edge is created; if it is not, the column still records it and this
340+
runs again when the superseder arrives.
341+
- *backward*: an already-stored claim names **this** one as its
342+
superseder, and could not have an edge until now.
343+
344+
The stale edge is dropped first, because `add` is an upsert: re-adding
345+
an id whose `superseded_by` changed must not leave the old edge behind,
346+
for the same reason the entity edges above are rebuilt rather than
347+
merged.
348+
"""
349+
self._conn.execute(
350+
"MATCH (c:Claim {id: $id})-[r:SUPERSEDED_BY]->(n:Claim) "
351+
"WHERE n.id <> coalesce(c.superseded_by, '') DELETE r",
352+
{"id": claim_id},
353+
)
354+
self._conn.execute(
355+
"MATCH (c:Claim {id: $id}), (n:Claim) WHERE c.superseded_by = n.id "
356+
"MERGE (c)-[:SUPERSEDED_BY]->(n)",
357+
{"id": claim_id},
358+
)
359+
self._conn.execute(
360+
"MATCH (c:Claim), (n:Claim {id: $id}) WHERE c.superseded_by = n.id "
361+
"MERGE (c)-[:SUPERSEDED_BY]->(n)",
362+
{"id": claim_id},
363+
)
278364

279365
def add(self, claim: Claim) -> Claim:
280366
with self._transaction():
@@ -301,8 +387,9 @@ def supersede(self, old_id: str, new_claim: Claim) -> Claim:
301387
{"old": old_id, "new": new_claim.id},
302388
)
303389
self._conn.execute(
304-
"MATCH (o:Claim {id: $old}) SET o.superseded_at = $at",
305-
{"old": old_id, "at": _now()},
390+
"MATCH (o:Claim {id: $old}) "
391+
"SET o.superseded_at = $at, o.superseded_by = $new",
392+
{"old": old_id, "at": _now(), "new": new_claim.id},
306393
)
307394
return new_claim
308395

@@ -313,13 +400,12 @@ def current(self, subject: str, predicate: str | None = None) -> list[Claim]:
313400
if predicate is not None:
314401
where += " AND c.predicate_norm = $predicate"
315402
params["predicate"] = _normalize(predicate)
316-
# The superseded test is on the edge, so it has to follow the OPTIONAL
317-
# MATCH rather than ride along in the WHERE above.
318-
cypher = (
319-
f"MATCH (c:Claim) {where} {_OPTIONAL_SUPERSEDER} "
320-
f"WITH c, n WHERE n IS NULL "
321-
f"RETURN {_PROJECTION} ORDER BY c.seq"
322-
)
403+
# The superseded test is on the column now, so it rides along in the
404+
# WHERE above instead of needing a WITH after an OPTIONAL MATCH. That is
405+
# not only tidier: filtering on the edge is what returned both sides of
406+
# an imported correction, because the edge was the half that got lost.
407+
where += " AND c.superseded_by IS NULL"
408+
cypher = f"MATCH (c:Claim) {where} RETURN {_PROJECTION} ORDER BY c.seq"
323409
with self._lock:
324410
result = self._conn.execute(cypher, params)
325411
return [_to_claim(row) for row in result.rows_as_dict()]

0 commit comments

Comments
 (0)