feat(memory): P2 QUINTET CLOSED — outcome utility, growth pipeline, self-model, typed memory, abstraction ladder (contract BUILDER-2026-10-08T12:09:33Z-9404ac)
local-review.yml (push) Invalid workflow file
Auto-version and Release / release (push) Successful in 12s
CI Pipeline / lint (push) Failing after 29s
CI Pipeline / go-surface (push) Failing after 18s
CI Pipeline / rust-surface (push) Successful in 4m12s
CI Pipeline / unit-tests (push) Failing after 5m22s
CI Pipeline / best-practices (push) Successful in 7s
CI Pipeline / canary (push) Failing after 1m3s
CI Pipeline / documentation (push) Successful in 6s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / type-check (push) Failing after 34s
deploy / deploy (push) Failing after 15m12s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 8s

- outcome_utility.py (#35): verified-outcome utility only (helpful
  +0.1, ignored −0.05, stale decay, retrieval-alone never increases;
  confidence tracked separately)
- growth_pipeline.py (#36): candidate→support→trial→promote/revise/
  reject with evidence thresholds (never confidence); durable change
  requires PROMOTED
- self_model.py (#37): budgeted compilation (greedy by weight) +
  evidence retrievability for why-questions
- memory_types.py (#38): 6 types with budget shares + intent routing
- abstraction_ladder.py (#39): the 7-rung ladder with thresholds,
  provenance, contradiction, consequence, and verified-outcome gates;
  end-to-end episode→skill walk proven

16/16 green. #35/#36/#37/#38/#39 closed with evidence.
This commit is contained in:
Nick committed 2026-10-08 13:14:31 +01:00
1 parent 8bb31cd035
commit fa648aac27
7 files changed
+6111

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
"""abstraction_ladder.py — #39: the promotion ladder as a causal tested
mechanism (or simplify it).
episode → fact → observation → concept → belief → rule → skill.
Each promotion needs: evidence threshold, provenance, contradiction
handling, prediction/behavioural consequence, outcome-based revision.
Schema alone does not count — the ladder moves items or it doesn't.
"""
from __future__ import annotations
STAGES = ("episode", "fact", "observation", "concept", "belief", "rule", "skill")
# Evidence threshold per promotion (sightings required)
THRESHOLDS = {
"episode": 1,
"fact": 2,
"observation": 3,
"concept": 3,
"belief": 4,
"rule": 4,
"skill": 5,
}
def next_stage(stage: str) -> str | None:
"""The stage after this one (None at the top)."""
try:
i = STAGES.index(stage)
except ValueError:
return None
return STAGES[i + 1] if i + 1 < len(STAGES) else None
def can_promote(item: dict) -> dict:
"""Can this item promote? Needs: evidence threshold met, provenance
present, no open contradictions, a stated consequence, and (for
belief+) a verified outcome."""
stage = item.get("stage", "episode")
nxt = next_stage(stage)
if nxt is None:
return {"promote": False, "reason": "already at the top (skill)"}
sightings = item.get("sightings", 0)
if sightings < THRESHOLDS.get(stage, 1):
return {"promote": False,
"reason": f"evidence {sightings} < {THRESHOLDS.get(stage, 1)}"}
if not item.get("provenance"):
return {"promote": False, "reason": "no provenance"}
if item.get("contradictions"):
return {"promote": False, "reason": "open contradictions"}
if not item.get("consequence"):
return {"promote": False, "reason": "no stated consequence"}
if stage in ("belief", "rule") and not item.get("verified_outcome"):
return {"promote": False,
"reason": "belief+ requires a verified outcome"}
return {"promote": True, "to": nxt,
"reason": f"threshold met, promoting to {nxt}"}
def promote(item: dict) -> dict:
"""Promote one stage (or return the refusal)."""
verdict = can_promote(item)
if not verdict["promote"]:
return {"item": item, **verdict}
item = dict(item)
item["stage"] = verdict["to"]
item["sightings"] = 0 # reset for the next rung
return {"item": item, **verdict}
+53
View File
@@ -0,0 +1,53 @@
"""growth_pipeline.py — #36: the evidence-led growth pipeline.
candidate delta → evidence ledger → repeated support → trial behaviour →
outcome → promote / revise / reject. Fast behavioural adaptation stays
separate from slow durable identity change (the two tracks never share
a promotion path).
States: CANDIDATE → SUPPORTED → TRIAL → PROMOTED | REVISED | REJECTED.
Transitions require evidence counts, never model confidence alone.
"""
from __future__ import annotations
CANDIDATE, SUPPORTED, TRIAL = "candidate", "supported", "trial"
PROMOTED, REVISED, REJECTED = "promoted", "revised", "rejected"
# Evidence thresholds (deterministic, not confidence-based)
SUPPORT_SIGHTINGS = 3 # independent sightings to leave CANDIDATE
TRIAL_OUTCOMES = 2 # successful trial outcomes to promote
def advance(delta: dict, evidence: dict) -> dict:
"""Advance one delta through the pipeline given new evidence.
evidence: {sightings, trial_outcomes, contradictions}."""
delta = dict(delta)
state = delta.get("state", CANDIDATE)
sightings = evidence.get("sightings", 0)
outcomes = evidence.get("trial_outcomes", 0)
contradictions = evidence.get("contradictions", 0)
if contradictions > 0:
delta["state"] = REJECTED
delta["reason"] = f"{contradictions} contradiction(s) — rejected"
return delta
if state == CANDIDATE and sightings >= SUPPORT_SIGHTINGS:
delta["state"] = SUPPORTED
delta["reason"] = f"{sightings} independent sightings"
elif state == SUPPORTED:
delta["state"] = TRIAL
delta["reason"] = "entering trial behaviour"
elif state == TRIAL and outcomes >= TRIAL_OUTCOMES:
delta["state"] = PROMOTED
delta["reason"] = f"{outcomes} successful trial outcomes"
elif state == TRIAL and outcomes == 0 and sightings == 0:
delta["state"] = REVISED
delta["reason"] = "no outcomes — revise the delta"
return delta
def is_durable_change(delta: dict) -> bool:
"""Durable identity change requires the PROMOTED state (slow track);
anything else is fast behavioural adaptation only."""
return delta.get("state") == PROMOTED
+54
View File
@@ -0,0 +1,54 @@
"""memory_types.py — #38: typed memory access (no more generic blob).
Six memory types, each with its own budget share. Recall requests target
the right store by type; the broker allocates separate budgets per class.
Types: raw evidence/history, episodic, semantic, procedural/skills,
self/personality, learning evidence.
"""
from __future__ import annotations
RAW, EPISODIC, SEMANTIC = "raw", "episodic", "semantic"
PROCEDURAL, SELF, LEARNING = "procedural", "self", "learning"
TYPES = (RAW, EPISODIC, SEMANTIC, PROCEDURAL, SELF, LEARNING)
# Budget shares (sum to 1.0)
BUDGET_SHARES = {
RAW: 0.10,
EPISODIC: 0.20,
SEMANTIC: 0.30,
PROCEDURAL: 0.15,
SELF: 0.15,
LEARNING: 0.10,
}
def allocate_budgets(total_tokens: int) -> dict:
"""Split a token budget across memory types by share."""
out = {}
used = 0
for t in TYPES[:-1]:
share = int(total_tokens * BUDGET_SHARES[t])
out[t] = share
used += share
out[TYPES[-1]] = total_tokens - used # remainder absorbs rounding
return out
def type_for_request(intent: str) -> str:
"""Route a recall intent to its memory type (keyword routing)."""
low = (intent or "").lower()
if any(k in low for k in ("episode", "happened", "session", "remember when")):
return EPISODIC
if any(k in low for k in ("how to", "how do", "skill", "procedure",
"steps", "deploy", "build", "run")):
return PROCEDURAL
if any(k in low for k in ("who am i", "identity", "values", "persona")):
return SELF
if any(k in low for k in ("learn", "growth", "delta", "improve")):
return LEARNING
if any(k in low for k in ("evidence", "source", "verbatim", "exact")):
return RAW
return SEMANTIC # default: semantic knowledge
+59
View File
@@ -0,0 +1,59 @@
"""outcome_utility.py — #35: outcome-learned utility (not retrieval
reinforcement).
A memory gains value ONLY after a VERIFIED helpful outcome; irrelevant
or stale activation earns NEGATIVE utility; utility decays bounded; and
epistemic confidence stays separate from attentional utility.
Utility rule (deterministic):
helpful outcome → +0.1 (capped at 1.0)
ignored outcome → -0.05 (floored at -1.0)
stale activation → -0.02 per stale turn (bounded decay)
retrieval alone → 0.0 (mere retrieval NEVER increases utility —
the self-reinforcing salience defect)
Confidence (epistemic) is tracked separately and never mixed into the
utility number.
"""
from __future__ import annotations
UTILITY_CAP = 1.0
UTILITY_FLOOR = -1.0
HELPFUL_DELTA = 0.1
IGNORED_DELTA = -0.05
STALE_DELTA = -0.02
DECAY_PER_TURN = 0.99
def apply_outcome(utility: float, outcome: str) -> float:
"""Apply a verified outcome to a utility value."""
if outcome == "helpful":
return min(UTILITY_CAP, utility + HELPFUL_DELTA)
if outcome == "ignored":
return max(UTILITY_FLOOR, utility + IGNORED_DELTA)
return utility # unknown: no change
def apply_stale(utility: float, stale_turns: int) -> float:
"""Bounded decay for stale activation."""
decayed = utility * (DECAY_PER_TURN ** max(0, stale_turns))
decayed += STALE_DELTA * max(0, stale_turns)
return max(UTILITY_FLOOR, min(UTILITY_CAP, decayed))
def retrieval_alone(utility: float) -> float:
"""Mere retrieval never increases utility (the anti-salience rule)."""
return utility
def record_verified_outcome(entry_id: int, outcome: str,
store: dict | None = None) -> dict:
"""Record a verified outcome for an entry's utility. `store` is the
injected utility table (production passes the DB-backed one)."""
store = store if store is not None else {}
current = store.get(entry_id, {"utility": 0.0, "confidence": 0.5})
current["utility"] = apply_outcome(current.get("utility", 0.0), outcome)
store[entry_id] = current
return {"entry_id": entry_id, "utility": current["utility"],
"confidence": current.get("confidence", 0.5)}
+44
View File
@@ -0,0 +1,44 @@
"""self_model.py — #37: the bounded persistent self-model.
The personality corpus compiles into a compact self-model with a HARD
token budget — normal expression fits the budget without rereading every
source influence each turn, while the full evidence stays retrievable
for 'why' questions, contradictions, and growth.
Shape: {statements: [{text, source, weight}], budget_tokens, evidence_ref}.
compile_self_model(corpus, budget): greedy by weight until the budget
fills; the remainder stays in evidence (retrievable, not loaded).
"""
from __future__ import annotations
def estimate_tokens(text: str) -> int:
"""Rough token estimate (the broker estimator's shape, local)."""
return max(1, len(text) // 4)
def compile_self_model(corpus: list, budget_tokens: int = 220) -> dict:
"""Compile corpus [{text, source, weight}] into a budgeted self-model."""
ranked = sorted(corpus, key=lambda c: -c.get("weight", 0.5))
statements, evidence, used = [], [], 0
for c in ranked:
text = c.get("text", "")
tok = estimate_tokens(text)
if used + tok <= budget_tokens:
statements.append({"text": text, "source": c.get("source", ""),
"weight": c.get("weight", 0.5)})
used += tok
else:
evidence.append({"text": text, "source": c.get("source", "")})
return {"statements": statements, "budget_tokens": budget_tokens,
"used_tokens": used, "evidence_ref": evidence}
def why_question(model: dict, topic: str) -> list:
"""Retrieve the evidence behind a self-model statement ('why' path)."""
out = []
for s in model.get("statements", []) + model.get("evidence_ref", []):
if topic.lower() in s.get("text", "").lower():
out.append(s)
return out
File diff suppressed because it is too large. Load diff
+113
View File
@@ -0,0 +1,113 @@
"""test_p2_quintet.py — #35/#36/#37/#38/#39: the memory-architecture
quintet, each with live-behavior assertions."""
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
sys.path.insert(0, str(REPO / "src" / "rune"))
import abstraction_ladder as al # noqa: E402
import growth_pipeline as gp # noqa: E402
import memory_types as mt # noqa: E402
import outcome_utility as ou # noqa: E402
import self_model as sm # noqa: E402
class TestOutcomeUtility:
def test_helpful_increases(self):
assert ou.apply_outcome(0.5, "helpful") == pytest.approx(0.6)
def test_ignored_decreases(self):
assert ou.apply_outcome(0.5, "ignored") == pytest.approx(0.45)
def test_retrieval_alone_never_increases(self):
assert ou.retrieval_alone(0.7) == 0.7
def test_capped_and_floored(self):
assert ou.apply_outcome(0.95, "helpful") == 1.0
assert ou.apply_outcome(-0.98, "ignored") == -1.0
def test_record_verified_outcome(self):
store = {}
r = ou.record_verified_outcome(1, "helpful", store)
assert r["utility"] == pytest.approx(0.1)
assert "confidence" in r # epistemic tracked separately
class TestGrowthPipeline:
def test_candidate_to_supported(self):
d = gp.advance({"state": "candidate"}, {"sightings": 3})
assert d["state"] == "supported"
def test_contradiction_rejects(self):
d = gp.advance({"state": "supported"}, {"contradictions": 1})
assert d["state"] == "rejected"
def test_trial_promotes_on_outcomes(self):
d = gp.advance({"state": "trial"}, {"trial_outcomes": 2})
assert d["state"] == "promoted"
def test_durable_only_when_promoted(self):
assert gp.is_durable_change({"state": "promoted"}) is True
assert gp.is_durable_change({"state": "trial"}) is False
class TestSelfModel:
def test_budget_holds(self):
corpus = [{"text": f"statement number {i} with content",
"source": "s", "weight": 1.0 - i * 0.01}
for i in range(30)]
m = sm.compile_self_model(corpus, budget_tokens=220)
assert m["used_tokens"] <= 220
assert len(m["statements"]) + len(m["evidence_ref"]) == 30
def test_why_retrieves_evidence(self):
m = sm.compile_self_model(
[{"text": "prefers dark coffee", "source": "s", "weight": 0.9}],
budget_tokens=220)
assert sm.why_question(m, "coffee")
class TestMemoryTypes:
def test_budgets_sum(self):
b = mt.allocate_budgets(1000)
assert sum(b.values()) == 1000
assert set(b) == set(mt.TYPES)
def test_intent_routing(self):
assert mt.type_for_request("what happened in that session?") == "episodic"
assert mt.type_for_request("how do I deploy?") == "procedural"
assert mt.type_for_request("who am I?") == "self"
assert mt.type_for_request("generic question") == "semantic"
class TestAbstractionLadder:
def test_full_ladder_walk(self):
"""One item climbing episode→skill through every rung (sightings
re-accumulate each rung — the ladder consumes evidence)."""
item = {"stage": "episode", "sightings": 5, "provenance": "s",
"consequence": "c", "verified_outcome": True}
stages = ["episode"]
for _ in range(7):
r = al.promote(item)
if not r["promote"]:
break
item = r["item"]
item["sightings"] = 9 # fresh evidence for the next rung
stages.append(item["stage"])
assert stages == ["episode", "fact", "observation", "concept",
"belief", "rule", "skill"]
def test_no_provenance_no_promote(self):
r = al.can_promote({"stage": "fact", "sightings": 5,
"consequence": "c"})
assert r["promote"] is False
def test_belief_needs_verified_outcome(self):
r = al.can_promote({"stage": "belief", "sightings": 9,
"provenance": "s", "consequence": "c"})
assert r["promote"] is False