diff --git a/haiku_rag_slim/haiku/rag/capabilities/ledger.py b/haiku_rag_slim/haiku/rag/capabilities/ledger.py index 4d837f79..2750bd6c 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/ledger.py +++ b/haiku_rag_slim/haiku/rag/capabilities/ledger.py @@ -65,14 +65,15 @@ class CapabilityEvidenceRecord(BaseModel): declaration: CitationDeclaration | None = None def _reject_regression(self, count: int, what: str) -> None: - """Refuse a message count below one already recorded. + """Refuse a message count below one already recorded in this question. Identities and epochs are both message counts, and every comparison - between them assumes the conversation only grows. One capability - truncating or reordering the history breaks that, and each way of - recording it has to refuse the same way: an unchecked evidence outcome - freezes every later declaration as stale, while an unchecked declaration - replaces a newer one with an older one and revives the answer it grounded. + between them assumes the conversation only grows while a question is + being answered. One capability truncating or reordering the history + breaks that, and each way of recording it has to refuse the same way: an + unchecked evidence outcome freezes every later declaration as stale, while + an unchecked declaration replaces a newer one with an older one and + revives the answer it grounded. """ recorded = max( self.question or 0, @@ -87,9 +88,30 @@ class CapabilityEvidenceRecord(BaseModel): ) def begin_question(self, identity: int) -> None: - """Take the identity of a question that has just arrived.""" - self._reject_regression(identity, "A question") + """Take the identity of a question that has just arrived. + + The evidence epoch and the declaration describe the question that has + just ended, and outlive their meaning the moment the next one begins: + kept, they let the last question's citations ground this one, and hold a + horizon this question's own declarations cannot pass. Clearing them + confines those comparisons to one question, so a host whose stored + history shifted between two of them is answered rather than refused. + + Identities themselves must still separate one question from the next. + Occurrences outlive the question that recorded them and carry the + identities that cited them, which compaction groups and orders the capsule + by: a reused identity merges two questions into one group, and a lower one + renders later evidence as though it were cited earlier. + """ + if self.question is not None and identity <= self.question: + raise ValueError( + f"A question at message count {identity} is not past question " + f"{self.question}, which is already answered: identities separate " + "one question from the next and are compared as recency." + ) self.question = identity + self.latest_evidence_epoch = 0 + self.declaration = None def note_evidence(self, epoch: int) -> None: """Record that the model has seen an evidence outcome. diff --git a/tests/capabilities/test_evidence_ledger.py b/tests/capabilities/test_evidence_ledger.py index fa849c64..c9f94353 100644 --- a/tests/capabilities/test_evidence_ledger.py +++ b/tests/capabilities/test_evidence_ledger.py @@ -47,14 +47,13 @@ def test_refs_make_it_grounded_and_no_refs_make_it_ungrounded(): def test_an_earlier_questions_declaration_is_never_current(): - """Epochs outlive a question, so the epoch alone would inherit it.""" + """A later question inherits nothing: it has declared nothing yet.""" record = CapabilityEvidenceRecord(question=2) record.declare([rag_ref()], epoch=3) assert citation_status([record], question=2) == "grounded" record.begin_question(8) - assert record.declaration is not None assert citation_status([record], question=8) == "missing" @@ -193,9 +192,51 @@ def test_evidence_cannot_predate_a_recorded_declaration(): record.note_evidence(3) -def test_a_question_cannot_start_before_what_is_already_recorded(): +def test_a_question_starts_behind_the_epochs_of_the_one_before_it(): + """A question's identity is the history it arrives on, not a continuation. + + Epochs are compared only within the question that recorded them, so a host + whose stored history shifted between two questions is answered rather than + refused. + """ record = CapabilityEvidenceRecord(question=0) record.note_evidence(9) - with pytest.raises(ValueError, match="append-only"): + record.begin_question(4) + + assert record.question == 4 + + +def test_a_question_cannot_reuse_the_identity_of_the_one_before_it(): + """Occurrences outlive their question and are ordered by identity. + + Two questions sharing one identity merge into a single capsule group, and a + lower one is rendered as though its evidence were cited earlier. + """ + record = CapabilityEvidenceRecord(question=4) + + with pytest.raises(ValueError, match="already answered"): record.begin_question(4) + + with pytest.raises(ValueError, match="already answered"): + record.begin_question(3) + + assert record.question == 4 + + +def test_a_question_starts_clear_of_the_one_before_it(): + """Evidence and declarations describe a single question and end with it. + + Carrying either into the next question makes it answerable by the last + question's citations, and freezes its own declarations behind an epoch no + message in it can reach. + """ + record = CapabilityEvidenceRecord(question=4) + record.note_evidence(6) + record.declare([rag_ref()], epoch=7) + + record.begin_question(9) + + assert record.latest_evidence_epoch == 0 + assert record.declaration is None + assert citation_status([record], question=9) == "missing"