fix(cognition): a promoted proposal must not un-mark a sibling's speculative material (H-13)

The speculative-marker cache seeds a subject AND each >=4-char token split from
it, so two independent proposals can claim the same token ("wisdom" from both
`wisdom` and `practical wisdom`). `_forget_speculative_subject` popped the token
outright on COHERENT promotion — regardless of which proposal had seeded it — so
promoting one proposal stripped the marker from another that was still
unreviewed. Unreviewed material was then served WITHOUT the "(speculative, not
yet reviewed)" prefix: the expensive failure direction, on the surface whose
whole job is telling reviewed knowledge from unreviewed.

_speculative_subjects becomes OrderedDict[str, int] — the value is a reference
count. Seeding increments and refreshes LRU position; promotion decrements and
evicts at zero. Seeding and eviction walk the same source list so counts
balance; where they disagree the count stays positive and the subject keeps its
marker, which is the honest direction to fail. An unmatched release floors at
removal and never goes negative, so it cannot borrow against a later proposal's
claim. The LRU cap survives unchanged as a size valve.

Four new pins in tests/test_speculative_subject_lifecycle.py, ALL FOUR OBSERVED
RED BEFORE GREEN — sibling token evicted, marker lost at the served surface,
repeated teaching cleared by one promotion, and count underflow. The eight
pre-existing lifecycle pins pass unchanged.

Also H-8e — expand_relation_closure's docstring claimed "Cycle: if path would
revisit a node, skip", describing a check the code does not perform. The code
was right: termination is structural (monotone growth over a finite triple set),
and a path-revisit skip would REFUSE SOUND transitive derivations, since a
witness path that revisits a node still proves a true fact. The docstring now
says that, and records that `path` is the witnessing step rather than full
ancestry — extending it would move operator_invocation and therefore trace_hash,
so it is deliberately not done here.

Registers updated: H-13 marked FIXED with the ratification note (this changes
served surfaces toward disclosure, restoring ADR-0021 §Articulation's stated
intent rather than extending it); H-8e marked CORRECTED.

Found while fixing it, recorded not acted on: tests/test_speculative_subject_
lifecycle.py — the file pinning this exact behavior — is in NO curated suite,
reachable only through `full`. G-7's mechanism, caught on the pin that would
have caught the defect. Assignment left to PR-4; adding it to TEST_SUITES
["smoke"] alone would widen N-3's ten-file delta and prejudge R-14.

Pre-existing ruff findings in pipeline.py (3 unused imports, 1 E402) confirmed
present before this change and left alone — module-top, not adjacent to the edit.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wcw2pnMBwyvmNyQg4uPEt4
This commit is contained in:
Claude 2026-07-28 03:24:20 +00:00
parent e025de7ad4
commit 71542e61ee
No known key found for this signature in database
4 changed files with 177 additions and 9 deletions

View file

@ -102,8 +102,25 @@ def expand_relation_closure(
Rules: Rules:
* Base facts: each input triple (normalized). * Base facts: each input triple (normalized).
* Step k+1: if (a,r,b) and (b,r,c) known and ac and (a,r,c) unknown, * Step k+1: if (a,r,b) and (b,r,c) known and ac and (a,r,c) unknown,
derive (a,r,c) with path ac. derive (a,r,c) with the witnessing path (a, b, c).
* Cycle: if path would revisit a node, skip (no infinite loop). * Termination is structural, not path-based: ``work`` grows
monotonically over a finite triple set, so the fixed point is
reached in at most ``budget`` steps. The only skips are
``a == c`` (a self-edge is not a new fact) and a triple already
in ``work``. **No path-revisit check is performed, and none
should be**: a witness path that revisits a node still proves a
true transitive fact, so skipping on revisit would refuse sound
derivations and leave the closure incomplete. (H-8e, 2026-07-28
this rule previously read "Cycle: if path would revisit a
node, skip", describing a check the code does not do and must
not do. The code was right; the sentence was wrong.)
* ``path`` records the *witnessing* step, ``(head, mid, tail)``, not
the full derivation ancestry: a depth-k edge names the one
intermediate that produced it, whose own path names the next.
Extending it to full ancestry would change ``as_dict`` output,
and therefore ``operator_invocation`` and ``trace_hash`` a
trace-bytes change, not a docstring fix, and deliberately not
made here.
* Contradiction: two different tails for the same (head, relation) * Contradiction: two different tails for the same (head, relation)
among base facts mark contradiction=True. among base facts mark contradiction=True.
* Geometric admissibility (Stage 3 exit gate): a candidate edge is * Geometric admissibility (Stage 3 exit gate): a candidate edge is

View file

@ -114,7 +114,7 @@ _UNIFY_EPS = 1e-4
# emits and small enough that the per-turn substring scan in # emits and small enough that the per-turn substring scan in
# ``_should_mark_speculative`` stays trivially cheap. LRU eviction: a # ``_should_mark_speculative`` stays trivially cheap. LRU eviction: a
# subject re-encountered as SPECULATIVE refreshes its position; coherent # subject re-encountered as SPECULATIVE refreshes its position; coherent
# promotion removes it explicitly. # promotion decrements its claim and removes it at zero.
_MAX_SPECULATIVE_SUBJECTS = 64 _MAX_SPECULATIVE_SUBJECTS = 64
class CognitiveTurnPipeline: class CognitiveTurnPipeline:
@ -158,7 +158,20 @@ class CognitiveTurnPipeline:
# forever and widened the per-turn substring scan unboundedly. # forever and widened the per-turn substring scan unboundedly.
# Iteration order matches insertion / refresh order; lookups # Iteration order matches insertion / refresh order; lookups
# remain O(1). # remain O(1).
self._speculative_subjects: OrderedDict[str, None] = OrderedDict() #
# H-13 (external-assessment triage, 2026-07-28) — the value is a
# REFERENCE COUNT, not a placeholder. Seeding splits a subject
# into tokens, so two independent proposals can claim the same
# token ("wisdom" from both ``wisdom`` and ``practical wisdom``).
# While this was a flat set, promoting either one popped the
# shared token and the *other* proposal — still unreviewed — lost
# its marker, serving speculative material as though reviewed.
# A token now survives while any unpromoted proposal still claims
# it. Seeding and eviction walk the same source list, so the
# counts balance; where they disagree the count stays positive
# and the subject keeps its marker, which is the honest direction
# to fail.
self._speculative_subjects: OrderedDict[str, int] = OrderedDict()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
@ -1027,32 +1040,52 @@ class CognitiveTurnPipeline:
return ratify_intent(intent, prompt_versor, vocab=vocab) return ratify_intent(intent, prompt_versor, vocab=vocab)
def _remember_speculative_subject(self, subject: str) -> None: def _remember_speculative_subject(self, subject: str) -> None:
"""Add (or refresh LRU position of) a speculative subject token. """Claim a speculative subject token (and refresh its LRU position).
Finding 5 (audit 2026-05-20). Caps the cache at Finding 5 (audit 2026-05-20). Caps the cache at
``_MAX_SPECULATIVE_SUBJECTS`` via insertion-order eviction. ``_MAX_SPECULATIVE_SUBJECTS`` via insertion-order eviction.
Empty / whitespace-only inputs are dropped silently so callers Empty / whitespace-only inputs are dropped silently so callers
can pass raw fragments without guarding. can pass raw fragments without guarding.
H-13 increments the token's reference count rather than
re-seating a placeholder, so a token claimed by two unreviewed
proposals needs two promotions to clear.
""" """
subject = subject.lower().strip() subject = subject.lower().strip()
if not subject: if not subject:
return return
self._speculative_subjects.pop(subject, None) count = self._speculative_subjects.get(subject, 0)
self._speculative_subjects[subject] = None self._speculative_subjects[subject] = count + 1
self._speculative_subjects.move_to_end(subject)
while len(self._speculative_subjects) > _MAX_SPECULATIVE_SUBJECTS: while len(self._speculative_subjects) > _MAX_SPECULATIVE_SUBJECTS:
self._speculative_subjects.popitem(last=False) self._speculative_subjects.popitem(last=False)
def _forget_speculative_subject(self, subject: str) -> None: def _forget_speculative_subject(self, subject: str) -> None:
"""Evict a subject from the speculative-marker cache. """Release one claim on a subject; evict it once no claim remains.
Called when a SPECULATIVE proposal is promoted to COHERENT via Called when a SPECULATIVE proposal is promoted to COHERENT via
the teaching review loop, so reviewed material stops being the teaching review loop, so reviewed material stops being
marked speculative on later probes. No-op if the subject is marked speculative on later probes. No-op if the subject is
not present. not present.
H-13 decrements rather than pops. Token seeding is
subject-split, so two proposals can claim one token; popping
outright let a promotion strip the marker from a *different*
proposal that was still unreviewed. An unmatched release (the
promoted proposal's token set differing from what it seeded)
floors at removal and never goes negative, so it cannot borrow
against a later proposal's claim.
""" """
subject = subject.lower().strip() subject = subject.lower().strip()
if subject: if not subject:
return
count = self._speculative_subjects.get(subject)
if count is None:
return
if count <= 1:
self._speculative_subjects.pop(subject, None) self._speculative_subjects.pop(subject, None)
else:
self._speculative_subjects[subject] = count - 1
def _should_mark_speculative(self, text: str, surface: str) -> bool: def _should_mark_speculative(self, text: str, surface: str) -> bool:
"""Decide whether ``surface`` should carry the SPECULATIVE marker. """Decide whether ``surface`` should carry the SPECULATIVE marker.

View file

@ -67,6 +67,7 @@ Ranked by leverage (cognitive/structural load removed ÷ effort), per the AGENTS
**(e) — added 2026-07-28 (external-assessment triage), and it is inside the code again.** `core/cognition/inductive_closure.py`'s docstring rule *"Cycle: if path would revisit a node, skip"* (`:106`) describes a check the implementation does not perform — it checks `a == c` and known-triple membership only (`:189-193`), **which is sufficient**: the fixed point is monotone over a finite triple set, so no path-based check is needed for termination, and adding one would *refuse sound transitive derivations* (a witness path that revisits a node still proves a true fact). Separately, `path` records only `(a, b, c)` even for derivations whose chain is longer (`:198`), so the provenance serialized into `operator_invocation` is incomplete for multi-step derivations. The remedy splits: the **docstring correction is mechanical** and belongs with the amendment batch; extending `path` to full provenance **changes `operator_invocation` bytes and therefore `trace_hash`**, so it is held to the trace-change bar and must not land silently. **(e) — added 2026-07-28 (external-assessment triage), and it is inside the code again.** `core/cognition/inductive_closure.py`'s docstring rule *"Cycle: if path would revisit a node, skip"* (`:106`) describes a check the implementation does not perform — it checks `a == c` and known-triple membership only (`:189-193`), **which is sufficient**: the fixed point is monotone over a finite triple set, so no path-based check is needed for termination, and adding one would *refuse sound transitive derivations* (a witness path that revisits a node still proves a true fact). Separately, `path` records only `(a, b, c)` even for derivations whose chain is longer (`:198`), so the provenance serialized into `operator_invocation` is incomplete for multi-step derivations. The remedy splits: the **docstring correction is mechanical** and belongs with the amendment batch; extending `path` to full provenance **changes `operator_invocation` bytes and therefore `trace_hash`**, so it is held to the trace-change bar and must not land silently.
**Better home:** five amendments — ADR-0146 addendum owning the daemon (drafted in `50-rulings.md` R-12a); ADR-0252 basis footnote (drafted, R-12b); a correction note on the 07-25 doc; the two docstrings corrected or the flag added, per R-3; and the inductive-closure docstring correction (e), mechanical. **Better home:** five amendments — ADR-0146 addendum owning the daemon (drafted in `50-rulings.md` R-12a); ADR-0252 basis footnote (drafted, R-12b); a correction note on the 07-25 doc; the two docstrings corrected or the flag added, per R-3; and the inductive-closure docstring correction (e), mechanical.
**Status (2026-07-28): (e) CORRECTED.** `expand_relation_closure`'s docstring now states that termination is structural (monotone growth over a finite triple set) and that **no path-revisit check is performed and none should be** — one would refuse sound transitive derivations. It also records that `path` is the *witnessing* step rather than full ancestry, and that extending it would move `operator_invocation` and `trace_hash`, so it is deliberately not done. (a)(d) remain open on their rulings.
**Authority:** docs PRs + ruling signatures (R-12 for the two ratified ADRs, R-3 for the docstrings). **Authority:** docs PRs + ruling signatures (R-12 for the two ratified ADRs, R-3 for the docstrings).
## H-9 · Dead instruments still standing as if live ## H-9 · Dead instruments still standing as if live
@ -104,6 +105,8 @@ Ranked by leverage (cognitive/structural load removed ÷ effort), per the AGENTS
**Why it hinders:** the marker is the honesty surface for unreviewed material, and the failure direction is served-unhedged — the direction this architecture treats as the expensive one. *(Provenance: surfaced by an external assessment that described this seam with the direction inverted — it claimed under-eviction; the code over-evicts. The verification that caught the inversion is what confirmed the real bug.)* **Why it hinders:** the marker is the honesty surface for unreviewed material, and the failure direction is served-unhedged — the direction this architecture treats as the expensive one. *(Provenance: surfaced by an external assessment that described this seam with the direction inverted — it claimed under-eviction; the code over-evicts. The verification that caught the inversion is what confirmed the real bug.)*
**Better home:** a reference-counted cache (token → count of live proposals; decrement on promotion, evict at zero), with the LRU cap retained as a size valve. **Failing test first:** promote one proposal, assert the sibling's marker survives — red before green. **Better home:** a reference-counted cache (token → count of live proposals; decrement on promotion, evict at zero), with the LRU cap retained as a size valve. **Failing test first:** promote one proposal, assert the sibling's marker survives — red before green.
**Authority:** mechanical PR + red-then-green test; flag at review whether ADR cover is wanted, since served-marker behavior changes (in the honest direction). **Authority:** mechanical PR + red-then-green test; flag at review whether ADR cover is wanted, since served-marker behavior changes (in the honest direction).
**Status (2026-07-28): FIXED.** `_speculative_subjects` is now `OrderedDict[str, int]` — a reference count; `_forget_speculative_subject` decrements and evicts at zero, flooring at removal so an unmatched release cannot borrow against a later proposal's claim. Four new pins in `tests/test_speculative_subject_lifecycle.py`, **all four observed red before green**; the eight pre-existing lifecycle pins pass unchanged. **Ratification note:** this changes served surfaces — markers now appear on turns where they previously vanished. The direction is toward disclosure, and it restores ADR-0021 §Articulation's stated intent rather than extending it, so it is offered as a defect fix; say if you want ADR cover before it ships.
**Found while fixing it (G-7's mechanism, live):** `tests/test_speculative_subject_lifecycle.py` — the file that pins this exact behavior — is **in no curated suite tuple**, reachable only through `full`. The pin that would have caught this defect on a pre-merge gate does not run on one. Assignment left to PR-4, which is designed to place every orphan; adding it to `TEST_SUITES["smoke"]` alone would widen N-3's ten-file delta and prejudge R-14's direction, so it was deliberately not done here.
## H-14 · The cognitive spine's turn method is 759 lines, with back-stamp repairs as the symptom ## H-14 · The cognitive spine's turn method is 759 lines, with back-stamp repairs as the symptom
**Verdict:** `strained` — structure debt on the highest-traffic path; H-4's sibling · **Layers:** M3/M4 **Verdict:** `strained` — structure debt on the highest-traffic path; H-4's sibling · **Layers:** M3/M4

View file

@ -91,3 +91,118 @@ def test_should_mark_speculative_still_iterates_correctly(
text="Tell me about wisdom", text="Tell me about wisdom",
surface="Wisdom is the application of knowledge.", surface="Wisdom is the application of knowledge.",
) )
# ---------------------------------------------------------------------------
# H-13 (external-assessment triage, 2026-07-28) — sibling eviction
# ---------------------------------------------------------------------------
# Seeding splits a subject into tokens, so two independent proposals can seed
# the SAME token ("wisdom" from both "wisdom" and "practical wisdom"). Under
# the pre-fix flat cache, promoting EITHER proposal to COHERENT popped the
# shared token outright — and the other proposal, still unreviewed, lost its
# marker. The failure direction is the expensive one: unreviewed material
# served WITHOUT the "(speculative, not yet reviewed)" prefix.
#
# These pin the reference-counted lifecycle: a token stays live while any
# unpromoted proposal still claims it.
def _seed_proposal(pipeline: CognitiveTurnPipeline, subject: str) -> None:
"""Mirror the seeding the pipeline performs for one SPECULATIVE proposal."""
pipeline._remember_speculative_subject(subject)
for tok in subject.lower().split():
if len(tok) >= 4:
pipeline._remember_speculative_subject(tok)
def _promote_proposal(pipeline: CognitiveTurnPipeline, subject: str) -> None:
"""Mirror the eviction the pipeline performs when that proposal goes COHERENT."""
pipeline._forget_speculative_subject(subject)
for tok in subject.lower().split():
if len(tok) >= 4:
pipeline._forget_speculative_subject(tok)
def test_promoting_one_proposal_keeps_a_sibling_token_live(
pipeline: CognitiveTurnPipeline,
) -> None:
"""Promoting 'practical wisdom' must not un-mark an unreviewed 'wisdom'."""
_seed_proposal(pipeline, "wisdom")
_seed_proposal(pipeline, "practical wisdom")
_promote_proposal(pipeline, "practical wisdom")
assert "wisdom" in pipeline._speculative_subjects, (
"the independent 'wisdom' proposal is still SPECULATIVE — promoting a "
"different proposal that merely shares the token must not evict it"
)
# The promoted proposal's own exclusive tokens are gone.
assert "practical wisdom" not in pipeline._speculative_subjects
assert "practical" not in pipeline._speculative_subjects
def test_sibling_survives_at_the_served_surface(
pipeline: CognitiveTurnPipeline,
) -> None:
"""The consequence that matters: the marker still fires for the sibling."""
_seed_proposal(pipeline, "wisdom")
_seed_proposal(pipeline, "practical wisdom")
_promote_proposal(pipeline, "practical wisdom")
assert pipeline._should_mark_speculative(
text="Tell me about wisdom",
surface="Wisdom is the application of knowledge.",
), "unreviewed material must keep its speculative marker"
def test_token_clears_once_every_claimant_is_promoted(
pipeline: CognitiveTurnPipeline,
) -> None:
"""Reference counting must still reach zero — no marker that never clears."""
_seed_proposal(pipeline, "wisdom")
_seed_proposal(pipeline, "practical wisdom")
_promote_proposal(pipeline, "practical wisdom")
_promote_proposal(pipeline, "wisdom")
assert "wisdom" not in pipeline._speculative_subjects
assert not pipeline._should_mark_speculative(
text="Tell me about wisdom",
surface="Wisdom is the application of knowledge.",
)
def test_repeated_speculative_teaching_needs_matching_promotions(
pipeline: CognitiveTurnPipeline,
) -> None:
"""Two unreviewed proposals on one subject take two promotions to clear."""
_seed_proposal(pipeline, "wisdom")
_seed_proposal(pipeline, "wisdom")
_promote_proposal(pipeline, "wisdom")
assert "wisdom" in pipeline._speculative_subjects, (
"one proposal reviewed, one still outstanding — still speculative"
)
_promote_proposal(pipeline, "wisdom")
assert "wisdom" not in pipeline._speculative_subjects
def test_unmatched_forget_never_underflows(
pipeline: CognitiveTurnPipeline,
) -> None:
"""A promotion whose seeding never happened must not push a count negative.
Seeding and eviction derive their token sets from the proposal object at
two different moments, so they can disagree. When they do, the safe
direction is to keep marking (a stale marker is honest-conservative; a
missing one is not) never a negative count that would let a later
promotion strip a live sibling.
"""
pipeline._remember_speculative_subject("wisdom")
pipeline._forget_speculative_subject("wisdom")
pipeline._forget_speculative_subject("wisdom") # unmatched
pipeline._remember_speculative_subject("wisdom")
assert "wisdom" in pipeline._speculative_subjects
assert pipeline._speculative_subjects["wisdom"] == 1