popoto.recipes.reconciliation¶
popoto.recipes.reconciliation
¶
M5 reconciliation: claim equivalence classes, typed contradiction rules, and explicit disjunctions over the provenance journal (#564).
The journal (:mod:popoto.recipes.provenance_journal) records every claim an
agent captures, immutably and with full attribution. What it does not do is
notice that two entries say the same thing. This module adds that layer: it
groups entries that assert one claim into an equivalence class, resolves
typed contradictions inside a class through a per-type precedence table, and
stores a precedence tie as an explicit disjunct pair rather than picking an
arbitrary winner.
Three properties shape every design choice here:
Nothing in this module ever mutates a persisted JournalEntry.
JournalEntry composes AppendOnlyMixin, whose save() refuses any
re-save of an existing key -- including a partial save(update_fields=[...]).
So class membership cannot be a field on the entry. It lives in two ordinary
(non-append-only) models this module owns outright, :class:ClaimMembership
and :class:ClaimClass, where a relabel is an ordinary save(). The only
writes M5 makes are (a) claim_type, set by capture before an entry's
first and only save(), (b) new appended annotation entries, and (c) rows in
its own two models.
The merge log is the source of truth; the two tables are a rebuildable
index. Every reconcile outcome is appended to the journal as an immutable
merge (or disjoin) annotation carrying the class ids, the rationale,
the timestamp and the convention-book version. :func:replay discards the
index and recomputes it from those annotations, which is what makes
reversibility structural rather than a property to be tested into existence:
retract a merge annotation, replay, and the pre-merge assignment is back. A
crash mid-relabel is a repair, not a corruption.
One reconciler per agent, processing entries sequentially -- the
single-writer invariant. This is a deployment constraint, not an
implementation detail, and it is what the two documented races take as their
mitigation. The :class:~popoto.streams.StreamConsumer on the "journal"
stream is the only production trigger, and being the sole writer is what
produces the invariant. :func:reconcile_entry is a thin adapter over the
same reconcile function that exists for tests to drive; it is not a
production entry point, because a host calling it from concurrent turn handling
has nothing establishing the sequencing. Running a second reconciler per agent
re-opens both races and requires reintroducing an atomic membership claim plus
per-claim-slot serialization.
Example::
from popoto.recipes.provenance_journal import ProvenanceJournal
from popoto.recipes.reconciliation import (
ClaimClass, representative_for, reconciliation_consumer,
)
from popoto.streams import StreamConsumer
# Capture assigns claim_type before the entry's first and only save().
ProvenanceJournal.append(
agent_id="a1",
statement="prefers morning meetings",
subjects=["dana"],
claim_type="preference",
)
# Production trigger: one consumer per agent, sequential.
consumer = reconciliation_consumer(agent_id="a1", consumer_name="worker-1")
# Downstream (M6/M7/M8) reads classes through the ORM.
for claim_class in ClaimClass.query.filter(agent_id="a1"):
entry, uncertain = representative_for(claim_class.class_id)
ClaimMembership
¶
Bases: Model
One row per reconciled entry: which class it belongs to.
Keyed by the entry's own Redis key, which gives exactly the identity
semantics wanted -- one row per entry, no duplicate-membership state to
reconcile. The colons inside that key are a non-issue: DB_key.clean()
escapes them before the value reaches the keyspace, so a composite key
value cannot forge a key boundary.
A plain Model deliberately -- no AppendOnlyMixin. A relabel on
merge must be an ordinary save().
Privacy invariant: this row holds a digest, never claim content.
claim_slot is a one-way sha256 of agent_id|subject|claim_type,
and neither this model nor :class:ClaimClass stores the subject string or
the plaintext claim_type. The reason is the exact scope of
JournalEntry.hard_delete(): it erases a record and every trace of its
own derived state, and explicitly not "every trace of the record anywhere
in the keyspace". A plaintext subject on a mutable sibling model would be
precisely such a field-value copy, sitting outside the reach of the only
erasure primitive an append-only record has -- and a sharper regression
than usual, because JournalEntry also composes NeverRecordMixin,
i.e. this data is already governed as never-record. Slot equality is all
reconciliation needs for grouping sibling claims, so the digest costs
nothing here. See :func:erase_entry for the cascade that keeps the
remaining derived state erasable.
Source code in src/popoto/recipes/reconciliation.py
ClaimClass
¶
Bases: Model
One row per equivalence class -- the read surface for M6/M7/M8.
Every read downstream needs is an ordinary indexed ORM query
(ClaimClass.query.filter(agent_id=...),
ClaimMembership.query.filter(class_id=...)) rather than an accessor
wrapping HGET/SMEMBERS, which is what makes this a real read
surface instead of a key convention a reader has to trust.
Holds no claim content, for the reason given on :class:ClaimMembership:
representative_key is a Redis key, and the entry it names is where the
claim text lives.
Source code in src/popoto/recipes/reconciliation.py
Sameness
¶
SamenessResult
dataclass
¶
One judge answer, or the reason there isn't one.
Attributes:
| Name | Type | Description |
|---|---|---|
verdict |
Optional[Sameness]
|
The parsed :class: |
abstained |
bool
|
True when no usable verdict was obtained. An abstention is
never read as |
reason |
str
|
A short machine token for the log and the merge-log rationale. Never model-authored text. |
Source code in src/popoto/recipes/reconciliation.py
ReconcileOutcome
dataclass
¶
What one reconcile pass did to one entry.
Attributes:
| Name | Type | Description |
|---|---|---|
entry_key |
str
|
The reconciled entry's Redis key. |
class_id |
str
|
The class it belongs to afterwards. |
action |
str
|
One of |
judge_calls |
int
|
Calls the judge actually issued, so AC5's bound is observable rather than asserted. |
disjunction_id |
str
|
The shared id when this pass stored a disjunct pair. |
superseded_key |
str
|
The loser's key when a type rule resolved. |
Source code in src/popoto/recipes/reconciliation.py
normalize_claim_type(claim_type)
¶
Return a type from :data:CLAIM_TYPES, falling back to note.
Tolerating None is a requirement, not a convenience: claim_type is
write-once-at-capture, so every entry captured before #564 shipped has
None and no code path can back-fill it. The reconciler must classify
those entries rather than skip them or raise. An unrecognized string is
treated the same way, so a capture path emitting a type outside the frozen
enum degrades to the rule-free catch-all instead of reaching a precedence
lookup with no row.
Source code in src/popoto/recipes/reconciliation.py
claim_slot(agent_id, subject, claim_type)
¶
Return the one-way slot digest for (agent_id, subject, claim_type).
32 hex characters of sha256. Computed at reconcile time and stored
one-way, so the slot supports the equality test grouping needs while
carrying none of the text -- see :class:ClaimMembership's privacy
invariant.
Source code in src/popoto/recipes/reconciliation.py
slot_for_entry(entry)
¶
Return entry's claim slot, using its first subject tag.
An entry with no subjects gets the empty subject, which puts every
untagged claim of one type into one slot. That is deliberate and matches
TagField's own zero-tag semantics: such an entry has no identity key to
compute, so it skips the deterministic tier and reaches the judge with a
subject-unbounded shortlist bounded by :data:M5_SHORTLIST_CAP.
Source code in src/popoto/recipes/reconciliation.py
judge_sameness(left_statement, right_statement, client=None)
¶
Ask once whether two claims are the same claim. Never raises.
Order of operations, mirroring llm_verdict:
- Either statement blank or whitespace-only -> abstain, zero calls. There is nothing to compare.
scan_never_recordruns on both statements before the call. A blocked statement abstains and its text is never transmitted.- Otherwise one call is issued. A malformed, empty or out-of-vocabulary reply, an unreachable provider, and a raising client all abstain.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
left_statement
|
str
|
The first claim. Order matters -- see
:func: |
required |
right_statement
|
str
|
The second claim. |
required |
client
|
Any
|
An Anthropic-style client (anything exposing
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
SamenessResult
|
class: |
SamenessResult
|
any model-authored text. |
Source code in src/popoto/recipes/reconciliation.py
cached_embedding(entry, provider=None)
¶
Return entry's statement vector, embedding and caching on a miss.
None when no provider is available or the provider fails, which is the
signal :func:shortlist_candidates uses to take its index-scan fallback.
Source code in src/popoto/recipes/reconciliation.py
drop_cached_embedding(redis_key, model=None)
¶
Delete one entry's cached vector.
Part of :func:erase_entry's cascade: an embedding is a lossy encoding of
statement, so the cache is content-derived state in a store
hard_delete() does not reach. model names the entry model, whose
backend holds the cache when it is not Redis (#759 M4).
Source code in src/popoto/recipes/reconciliation.py
shortlist_candidates(entry, *, exclude_class_ids=(), provider=None)
¶
Return at most :data:M5_SHORTLIST_CAP candidate class ids for entry.
Ranked by cosine similarity between the entry's statement vector and each class representative's, over the same-agent classes. When no embedding provider is available -- or the provider fails -- this degrades to a bounded same-subject + same-type index scan: recall narrows, every correctness property holds, and the judge-call bound is unchanged (Risk 4).
Source code in src/popoto/recipes/reconciliation.py
confirmation_count(entry)
¶
Return entry's corroboration count.
Derived by counting confirm annotations, never stored on the record:
ProvenanceJournal.confirm appends a new annotation and leaves the
target untouched, which is what makes corroboration append-only-safe.
Source code in src/popoto/recipes/reconciliation.py
representative_for(class_id)
¶
Return (representative_entry, uncertainty_flag) for a class.
The representative is the class's most-confirmed member among validity-open entries only, ties broken by recency. That is what the judge is always shown, and it is M5's guarantee to M6: one representative per class is selectable.
The second element is the uncertainty marker, and it is returned here,
from M5's own selection call, rather than left to a reader's formatting
layer: it is True when any live member of the class carries an open
disjunction_id, i.e. the class holds a precedence tie no winner was
picked for. A reader that ignores the flag degrades to showing a
representative; it cannot be handed a silent winner, because there isn't
one to hand.
Returns:
| Type | Description |
|---|---|
Tuple[Optional[Any], bool]
|
|
Source code in src/popoto/recipes/reconciliation.py
resolve_precedence(challenger, incumbent, claim_type)
¶
Resolve a fired type rule into (winner, loser, basis).
Applied only after a type rule fires: this never decides sameness, and
it never runs on a note.
Global Rule 0 is evaluated first for every type -- a self-stated claim
beats an inferred one, consuming M1's stated flag -- and the per-type
row applies only when both claims agree on stated. Then the family
order from :data:PRECEDENCE_ORDER: recency for the supersession family
(deadline), confirmation count then recency for the stable family.
The table is total. When Rule 0 ties, the family order ties, and recency
ties, this returns (None, None, PRECEDENCE_TIE) and the caller stores a
disjunct pair -- never an arbitrary winner.
Source code in src/popoto/recipes/reconciliation.py
merge_log_entries(agent_id)
¶
Return an agent's live merge-log annotations, oldest first.
Only validity__current=True annotations are returned, which is what
makes AC4 work: retracting a merge annotation closes its interval, so
the next :func:replay no longer sees it and reproduces the pre-merge
assignment.
Source code in src/popoto/recipes/reconciliation.py
reconcile_entry(entry, *, client=None, provider=None)
¶
Reconcile one entry directly. Test-only -- not a production path.
A thin adapter over the same reconcile function the stream consumer drives. One loop, one production trigger, never two pipelines.
This is deliberately not documented as a host-facing API. The
single-writer invariant that Races 1 and 3 take as their mitigation is not
a property of the reconcile function; it is produced by the consumer
being the sole writer, one reconciler per agent processing entries
sequentially. A consumer-less host calling this from concurrent turn
handling has nothing establishing that sequencing, which re-opens exactly
the concurrent-join hazard the invariant covers. Use
:func:reconciliation_consumer in production.
Source code in src/popoto/recipes/reconciliation.py
replay(agent_id, *, since=None, rebuild=False)
¶
Rebuild the class index from the merge log. Returns entries replayed.
The merge log is the source of truth and these two tables are a rebuildable
index derived from it, so there is no sidecar to keep in sync: a divergent
index is discarded and recomputed rather than reconciled against the log.
That is what makes AC4's reversibility structural -- retract a merge
annotation, replay, and the pre-merge assignment is back, because
:func:merge_log_entries only reads live annotations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent_id
|
str
|
The agent whose index to rebuild. |
required |
since
|
Optional[float]
|
Replay only annotations strictly newer than this instant
( |
None
|
rebuild
|
bool
|
Delete this agent's existing index rows first. Required for a true from-genesis rebuild; without it a replay is additive. |
False
|
Source code in src/popoto/recipes/reconciliation.py
1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 | |
erase_entry(entry)
¶
Erase a reconciled entry and every trace of M5's derived state.
Use this, not JournalEntry.hard_delete() directly, for a
reconciled entry. hard_delete() is the retention/erasure primitive and
its documented scope is the record plus every trace of its own derived
state -- explicitly not "every trace of the record anywhere in the
keyspace". M5 adds derived state the primitive therefore does not reach, so
a bare hard_delete() leaves a dangling membership row and, worse, a
:class:ClaimClass whose representative_key points at an erased key.
Four legs, in order:
JournalEntry.hard_delete()on the entry.- Delete its :class:
ClaimMembershiprow. - Delete its cached reconciler-side embedding -- an embedding is a lossy
encoding of
statement, so the cache is content-derived state in a storehard_delete()does not reach. - Recompute the affected :class:
ClaimClass: reselectrepresentative_keyand decrementmember_count, or drop the row when the class is left empty.
The membership row is survivable at all only because it carries the
one-way claim_slot digest and no claim content -- which matters
because JournalEntry also composes NeverRecordMixin, so this data
is governed as never-record.
Source code in src/popoto/recipes/reconciliation.py
make_reconciliation_handler(agent_id=None, *, client=None, provider=None)
¶
Build the StreamConsumer handler that is M5's production trigger.
Entries are processed sequentially inside one handler call, and the deployment contract is one consumer per agent. That sequencing is what produces the single-writer invariant: entry A's membership row is committed before entry B is shortlisted, so the interleaved shortlist-to-commit span Race 3 needs never occurs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent_id
|
Optional[str]
|
When given, only this agent's entries are reconciled --
filtered on the stream's |
None
|
client
|
Any
|
Judge client, forwarded to the reconcile function. |
None
|
provider
|
Any
|
Embedding provider, forwarded to the shortlist. |
None
|
Source code in src/popoto/recipes/reconciliation.py
reconciliation_consumer(*, agent_id=None, consumer_name, group_name='m5-reconciler', client=None, provider=None)
¶
Build the journal-stream consumer. One per agent -- see the docstring.
Running a second reconciler for one agent is a deployment error, not a state this code arbitrates: it re-opens Races 1 and 3 and requires reintroducing an atomic membership claim plus per-claim-slot serialization at the same time. Whoever proposes the second writer owns that change.