diff --git a/core/cognition/inductive_closure.py b/core/cognition/inductive_closure.py index 5eab4e48..9f892f12 100644 --- a/core/cognition/inductive_closure.py +++ b/core/cognition/inductive_closure.py @@ -102,8 +102,25 @@ def expand_relation_closure( Rules: * Base facts: each input triple (normalized). * Step k+1: if (a,r,b) and (b,r,c) known and a≠c and (a,r,c) unknown, - derive (a,r,c) with path a…c. - * Cycle: if path would revisit a node, skip (no infinite loop). + derive (a,r,c) with the witnessing path (a, b, c). + * 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) among base facts mark contradiction=True. * Geometric admissibility (Stage 3 exit gate): a candidate edge is diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index a38ac504..e61ceab6 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -114,7 +114,7 @@ _UNIFY_EPS = 1e-4 # emits and small enough that the per-turn substring scan in # ``_should_mark_speculative`` stays trivially cheap. LRU eviction: a # 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 class CognitiveTurnPipeline: @@ -158,7 +158,20 @@ class CognitiveTurnPipeline: # forever and widened the per-turn substring scan unboundedly. # Iteration order matches insertion / refresh order; lookups # 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 @@ -1027,32 +1040,52 @@ class CognitiveTurnPipeline: return ratify_intent(intent, prompt_versor, vocab=vocab) 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 ``_MAX_SPECULATIVE_SUBJECTS`` via insertion-order eviction. Empty / whitespace-only inputs are dropped silently so callers 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() if not subject: return - self._speculative_subjects.pop(subject, None) - self._speculative_subjects[subject] = None + count = self._speculative_subjects.get(subject, 0) + self._speculative_subjects[subject] = count + 1 + self._speculative_subjects.move_to_end(subject) while len(self._speculative_subjects) > _MAX_SPECULATIVE_SUBJECTS: self._speculative_subjects.popitem(last=False) 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 the teaching review loop, so reviewed material stops being marked speculative on later probes. No-op if the subject is 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() - 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) + else: + self._speculative_subjects[subject] = count - 1 def _should_mark_speculative(self, text: str, surface: str) -> bool: """Decide whether ``surface`` should carry the SPECULATIVE marker. diff --git a/docs/assessment/31-hindrance-audit.md b/docs/assessment/31-hindrance-audit.md index 058e7f5f..0c844948 100644 --- a/docs/assessment/31-hindrance-audit.md +++ b/docs/assessment/31-hindrance-audit.md @@ -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. **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). ## 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.)* **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). +**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 **Verdict:** `strained` — structure debt on the highest-traffic path; H-4's sibling · **Layers:** M3/M4 diff --git a/tests/test_speculative_subject_lifecycle.py b/tests/test_speculative_subject_lifecycle.py index c3f345b8..e6101e0d 100644 --- a/tests/test_speculative_subject_lifecycle.py +++ b/tests/test_speculative_subject_lifecycle.py @@ -91,3 +91,118 @@ def test_should_mark_speculative_still_iterates_correctly( text="Tell me about wisdom", 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