v1.1 WP-B.2: independent long-term memory retention

Corrects the memory mechanisms WP-B.1 diagnosed, one at a time, each
verified before the next. Accepted by the owner with a documented
reference-model limitation. No schema, bundle format, setting default,
lineage, authority or protocol-cleanup change.

- B2.1 ranking: the retrieval query is the player's input plus a bounded
  scene context (state scene + end of the newest narration), embedded in
  one call. final = semantic (0.6 input / 0.4 context) + 0.15 x lexical,
  where lexical is a rarity-weighted share of the input's words, computed
  per turn over the candidates with no index. Scores and the query are
  recorded per used memory; pins and redundancy suppression unchanged.
- B2.2 coverage-aware eviction (memorybank.eviction_order): the earliest
  and newest memories are kept, the smallest coverage hole goes first,
  least-recently-used breaks ties and remains the fallback. Bounded; pins
  never evicted; frozen-bank protection kept; reads no text or vectors.
- B2.3 bounded memory creation: a block longer than 2,000 tokens is shown
  to the summariser as head + tail with an omission marker, inside the
  same budget; shorter blocks unchanged; the marker is never stored.
- The memory summariser prompt is unchanged from v1.0.0. A B2.4 prompt
  experiment was measured on the reference model, showed no reliable
  improvement for the target failure (0/5 under both prompts, with new
  "Memory:"-prefix, second-person and length regressions), and was
  reverted. memorybank.memory_user_prompt is kept as a behaviour-neutral
  helper.
- tools/memory_fidelity.py (diagnostic only): genre-neutral fixtures plus
  the failed block, a deterministic fidelity checker, and a real-model
  shipped-vs-experiment measurement.
- tools/memory_diagnostic.py: ranking replica uses production scoring;
  ranking_crowded, ranking_context_dependent and independent_full
  fixtures; per-turn isolation and provenance.
- tests: B.1's two strict xfails are now ordinary passes; ranking,
  eviction and excerpt tests; summariser acceptance tests kept apart from
  diagnostic-measurement tests.
- DEVELOPMENT.md: the GPU-host kernel/Ollama watch used `-k -u ollama`,
  which matches nothing; now the OR form.
- docs: CONTEXT-AND-MEMORY 15/18/20/21 as shipped, V1.1-PLAN (status and
  release criteria 12-13), planning README, VERSION v4.3,
  reports/v1.1/V1.1-WP-B2-REPORT.md.

Deterministic independent-memory recovery: PASS (independent_full fails
on v1.0.0 at creation and returns recovered_through_memory_independent
here). Reference-model independent recovery: FAILED on the
precondition-valid attempt, at memory creation: the summariser omitted a
player-established fact from a block it received whole. Accepted as a
documented v1.1 residual and carried into the release gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvegagkhuCZoFPdv4M1egY
This commit is contained in:
JesseMarkowitz
2026-09-15 11:21:53 -04:00
co-authored by Claude Opus 5
parent beb17ada10
commit 0c1ba836ba
15 changed files with 3782 additions and 186 deletions
+165 -44
View File
@@ -255,70 +255,101 @@ def _section(snapshot: dict, label: str) -> str:
# ------------------------------------------------------------------- stages
async def rank_bank(db, adventure, settings, query: str, embed) -> dict:
async def rank_bank(db, adventure, settings, query: dict, embed) -> dict:
"""Production's ranking, recomputed for `query`, for every eligible memory.
The same catalogue clause, the same cosine, the same pin rule, the same
redundancy suppression helper. Returns every scored row, not just the top-k,
because "where did F rank" is the question.
`query` is a `memorybank.retrieval_query` dict. The catalogue clause, the
scoring (`memorybank.score_candidates`) and the selection with its pins and
redundancy rule (`memorybank.select_memories`) are production's own
functions, so this is production's ranking, not a second opinion. Returns
every scored row, not just the top-k, because "where did F rank" is the
question.
"""
catalogue = db.execute(
select(models.Memory.id, models.Memory.pinned, models.Memory.authority,
models.Memory.embedding_blob).where(
models.Memory.embedding_blob, models.Memory.text).where(
models.Memory.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Memory),
models.Memory.forgotten.is_(False),
models.Memory.embedded.is_(True),
)
).all()
if not catalogue or not query.strip():
return {"query": query, "scored": [], "selected": [], "top_k": settings.memory_top_k}
[query_vec] = await embed([query])
held = {row.id: vectors.unpack(row.embedding_blob) for row in catalogue if row.embedding_blob}
authority_of = {row.id: row.authority for row in catalogue}
scored = sorted(
((vectors.cosine(query_vec, held[row.id]), row.id, row.pinned)
for row in catalogue if row.id in held),
key=lambda r: r[0], reverse=True,
)
top_k = max(1, settings.memory_top_k)
used = [r for r in scored if r[2]]
remaining = max(0, top_k - len(used))
candidates = [r for r in scored if not r[2]]
kept, suppressed = memorybank._drop_redundant(candidates, held, authority_of, remaining)
selected = {r[1] for r in used + kept}
texts = [t for t in (query["input"], query["context"]) if t.strip()]
if not catalogue or not texts:
return {"query": query, "scored": [], "selected": [], "top_k": top_k}
vectors_by_text = dict(zip(texts, await embed(texts)))
input_vec = vectors_by_text.get(query["input"]) if query["input"].strip() else None
context_vec = vectors_by_text.get(query["context"]) if query["context"].strip() else None
held = {row.id: vectors.unpack(row.embedding_blob) for row in catalogue if row.embedding_blob}
terms_of = ({row.id: memorybank.lexical_terms(row.text or "") for row in catalogue}
if query["input_terms"] else {})
authority_of = {row.id: row.authority for row in catalogue}
pinned_of = {row.id: row.pinned for row in catalogue}
scored = memorybank.score_candidates(
[row.id for row in catalogue if row.id in held], held, terms_of,
input_vec, context_vec, query["input_terms"])
used, suppressed = memorybank.select_memories(scored, pinned_of, held, authority_of, top_k)
selected = {row[1] for row in used}
suppressed_by = dict(suppressed)
return {
"query": query,
"top_k": top_k,
"scored": [
{"rank": i + 1, "memory_id": memory_id, "similarity": round(score, 4),
"pinned": pinned, "selected": memory_id in selected,
{"rank": i + 1, "memory_id": memory_id, "similarity": round(semantic, 4),
"semantic_score": round(semantic, 4), "lexical_score": round(lexical, 4),
"final_score": round(final, 4),
"pinned": pinned_of[memory_id], "selected": memory_id in selected,
"suppressed_as_duplicate_of": suppressed_by.get(memory_id)}
for i, (score, memory_id, pinned) in enumerate(scored)
for i, (final, memory_id, semantic, lexical) in enumerate(scored)
],
"selected": sorted(selected),
}
def production_query(adventure, exclude_action_id: int | None) -> str:
"""The retrieval query a turn used: its newest actions, as `retrieve_memories` builds it."""
recent = history.tail(adventure, memorybank.RETRIEVAL_WINDOW_ACTIONS, exclude_action_id)
return builder.truncate_to_last_tokens(
"\n\n".join(a.text for a in recent), memorybank.RETRIEVAL_WINDOW_TOKENS)
def production_query(adventure, exclude_action_id: int | None) -> dict:
"""The retrieval query a turn used, built by production's own `retrieval_query`."""
return memorybank.retrieval_query(adventure, exclude_action_id)
def variant_query(base: dict, player_input: str) -> dict:
"""`base` with a different player input: "what if the player had asked this
here", with the scene and narration context the recall turn really had."""
return {"input": player_input, "context": base["context"],
"input_terms": sorted(memorybank.lexical_terms(player_input))}
def eviction_order(db, adventure) -> list[int]:
"""The order `_evict_over_capacity` would take unpinned active memories in."""
from sqlalchemy import func
return db.execute(
select(models.Memory.id).where(
"""The order `_evict_over_capacity` would take unpinned active memories in.
Production's own `memorybank.eviction_order`, run to the end of the bank."""
rows = db.execute(
select(models.Memory.id, models.Memory.pinned, models.Memory.source_start,
models.Memory.source_end, models.Memory.last_used_at,
models.Memory.created_at, models.Memory.use_count).where(
models.Memory.adventure_id == adventure.id,
models.Memory.forgotten.is_(False),
models.Memory.pinned.is_(False),
).order_by(func.coalesce(models.Memory.last_used_at, models.Memory.created_at),
models.Memory.use_count)
).scalars().all()
)
).all()
return memorybank.eviction_order(rows, len(rows))
def coverage(ranges: list[tuple[int, int]], tip: int | None) -> dict:
"""How much of the story `ranges` (active memories' source ranges) describe.
`largest_gap` is the longest run of depths, between the first memory's start
and `tip`, that no memory covers. It is how the eviction rule is judged in
general, not only for the planted fact."""
if not ranges:
return {"first_start": None, "last_end": None, "largest_gap": None}
ordered = sorted(ranges)
gaps = []
reach = ordered[0][1]
for start, end in ordered[1:]:
gaps.append(max(0, start - reach - 1))
reach = max(reach, end)
return {"first_start": ordered[0][0], "last_end": reach,
"largest_gap": max(gaps, default=0)}
async def diagnose(db, adventure, settings, fact: Fact, plant_depth: int, *,
@@ -342,7 +373,7 @@ async def diagnose(db, adventure, settings, fact: Fact, plant_depth: int, *,
for memory in covering:
block = memorybank.source_block(db, memory)
raw = "\n\n".join(a.text for a in block)
excerpt = builder.truncate_to_last_tokens(raw, memorybank.MEMORY_EXCERPT_TOKENS)
excerpt = memorybank.memory_excerpt(raw) # what `summarize_block` sends
creation_input.append({
"memory_id": memory.id, "source_start": memory.source_start,
"source_end": memory.source_end, "block_tokens": builder.count_tokens(raw),
@@ -394,6 +425,8 @@ async def diagnose(db, adventure, settings, fact: Fact, plant_depth: int, *,
out["verdict"] = "created_but_evicted"
return out
# The recall turn's AI node is excluded, so the newest action is the recall
# player action, exactly as the turn saw it before it wrote its reply.
query = production_query(adventure, recall_action.id)
ranking = await rank_bank(db, adventure, settings, query, embed)
row = next((r for r in ranking["scored"] if r["memory_id"] == memory.id), None)
@@ -401,9 +434,10 @@ async def diagnose(db, adventure, settings, fact: Fact, plant_depth: int, *,
out["ranked"] = {
"yes": row is not None and row["rank"] <= ranking["top_k"],
"eligible": row is not None,
"lexical_score": None, # memory ranking has no lexical term (CONTEXT-AND-MEMORY §20)
"semantic_score": row["similarity"] if row else None,
"final_score": row["similarity"] if row else None,
"lexical_score": row["lexical_score"] if row else None,
"semantic_score": row["semantic_score"] if row else None,
"final_score": row["final_score"] if row else None,
"selected_top_k": ranking["selected"],
"pin_effect": "always selected" if memory.pinned else "none",
"rank": row["rank"] if row else None,
"of": len(ranking["scored"]),
@@ -512,6 +546,9 @@ PLACES = (
)
PARAPHRASE_QUERY = "I ask Mara where she tucked the little brass dial that tells the hour."
UNRELATED_QUERY = "I ask the ferryman what rope costs at the landing this season."
#: Built only from words every fixture memory holds ("travellers", "spent",
#: "time"), so its rarity weight is zero everywhere.
COMMON_WORDS_QUERY = "The travellers spent time."
def filler_prose(index: int, words: int) -> str:
@@ -539,6 +576,10 @@ class Scenario:
pin_first_memory: bool = False
lineage_control: bool = False
diagnose_recall: bool = True
#: The narration of the last turn before recall, when a fixture needs the
#: scene to say something (WP-B.2's context-dependent question). Must not
#: name a planted fact.
pre_recall_reply: str = ""
SCENARIOS = {
@@ -553,6 +594,23 @@ SCENARIOS = {
"long_block_fact_late": Scenario("long_block_fact_late", turns=10, prose_words=850,
plant_turn=3, budget=16384),
"lineage_control": Scenario("lineage_control", lineage_control=True),
# v1.1 WP-B.2: the ranking failure B.1 saw on the real model, made
# deterministic. Longer narration fills the v1.0.0 query, and `memory_top_k`
# is the real run's 4. Below capacity, isolation valid.
"ranking_crowded": Scenario("ranking_crowded", prose_words=150, top_k=4),
# v1.1 WP-B.2: all three B.1 failures at once. Every block is longer than the
# summariser's excerpt, narration crowds the query at `memory_top_k` 4, and
# the bank passes a capacity of 8 long before recall at depth 106.
"independent_full": Scenario("independent_full", prose_words=850, top_k=4,
capacity=8, budget=16384),
# v1.1 WP-B.2: a question that names neither the sundial nor the teapot and
# cannot be answered without the scene. The last narration puts Mara at the
# tavern's top shelf; the player asks "her" what she put "up there".
"ranking_context_dependent": Scenario(
"ranking_context_dependent", prose_words=150, top_k=4,
recall_text="I ask her what she keeps up there.",
pre_recall_reply=("Mara stands on a stool at the tavern's top shelf, running a cloth "
"around the old kettle up there, and she will not meet your eye.")),
}
@@ -662,6 +720,16 @@ def run_scenario(scenario: Scenario) -> dict:
models.Memory.created_at).where(models.Memory.adventure_id == adv)
.order_by(models.Memory.id)).all()]
def per_turn_isolation(adventure_id):
with SessionLocal() as db:
adventure = db.get(models.Adventure, adventure_id)
active = summaries.current(db, adventure)
return {
"f_in_state": FACT_F.mentioned_by(json.dumps(adventure.narrative_state or {},
default=str)),
"f_in_summary": FACT_F.mentioned_by(active.text if active is not None else ""),
}
def turn(kind, text, reply):
ScriptNarrator.next_reply = f"{reply}\n```state\n{{\"events\": []}}\n```"
response = client.post(f"/api/adventures/{adv}/actions", json={"type": kind, "text": text})
@@ -703,6 +771,8 @@ def run_scenario(scenario: Scenario) -> dict:
turn("do", f"I turn away from the mill and walk to the {PLACES[n % len(PLACES)]}.",
filler_prose(n + 100, scenario.prose_words))
g["diverged_at_turn"] = n
elif n == scenario.turns and scenario.pre_recall_reply:
turn("do", "I head back to the tavern.", scenario.pre_recall_reply)
else:
turn("do", f"I walk on to the {PLACES[n % len(PLACES)]}.",
filler_prose(n, scenario.prose_words))
@@ -735,6 +805,11 @@ def run_scenario(scenario: Scenario) -> dict:
"f_memory_id": f_memory_id,
"f_forgotten": bool(f_row and f_row["forgotten"]),
"f_use_count": f_row["use_count"] if f_row else None,
"coverage": coverage([(r["source_start"], r["source_end"]) for r in rows
if not r["forgotten"] and r["source_start"] is not None],
None),
# Isolation on every turn, not only at recall (WP-B.2 acceptance).
**per_turn_isolation(adv),
})
turn("do", scenario.recall_text, filler_prose(999, scenario.prose_words))
@@ -757,18 +832,64 @@ def run_scenario(scenario: Scenario) -> dict:
db, adventure, settings, FACT_F, plant_depth,
recall_action=recall_action, embed=embedder.embed))
result["summariser_excerpts"] = len(summariser.excerpts)
# Provenance as the recall turn recorded it, resolved back to rows.
used = (recall_action.context_snapshot.get("memories") or {}).get("used") or []
f_entry = next((m for m in used
if m.get("id") == result["diagnosis"]["created"]["memory_id"]), None)
f_memory = db.get(models.Memory, f_entry["id"]) if f_entry else None
block = memorybank.source_block(db, f_memory) if f_memory is not None else []
result["provenance"] = {
"recorded": f_entry and {k: f_entry.get(k) for k in
("id", "source", "semantic_score", "lexical_score",
"final_score", "authority")},
"range_covers_plant": bool(f_entry and f_entry["source"]["source_start"]
<= plant_depth <= f_entry["source"]["source_end"]),
"matches_row": bool(f_memory is not None and f_entry["source"] == {
"branch_id": f_memory.branch_id, "depth": f_memory.depth,
"source_start": f_memory.source_start, "source_end": f_memory.source_end}),
"source_block_depths": [a.depth for a in block],
"source_block_holds_planting": any(a.text == FACT_F.sentence for a in block),
}
memory_id = result["diagnosis"]["created"]["memory_id"]
if memory_id is not None and not result["diagnosis"]["retained"]["forgotten"]:
variants = {}
for label, query in (("direct", scenario.recall_text),
("paraphrase", PARAPHRASE_QUERY),
("unrelated", UNRELATED_QUERY)):
base = production_query(adventure, recall_action.id)
# WP-B.2's negative control needs a decoy: another memory that
# holds a word the question adds, and nothing about F.
decoy = next(((m.id, found.group(1)) for m in db.execute(
select(models.Memory).where(models.Memory.adventure_id == adventure.id,
models.Memory.forgotten.is_(False),
models.Memory.id != memory_id)
.order_by(models.Memory.id)).scalars()
if (found := re.search(r"at the ([a-z]+ [a-z]+)\.", m.text or ""))), None)
queries = [
("direct", variant_query(base, scenario.recall_text)),
("paraphrase", variant_query(base, PARAPHRASE_QUERY)),
("unrelated", variant_query(base, UNRELATED_QUERY)),
# The player's words with the context taken away.
("input_only", {**variant_query(base, scenario.recall_text), "context": ""}),
# Words every memory in these fixtures holds, and nothing else.
("common_words", variant_query(base, COMMON_WORDS_QUERY)),
]
if decoy is not None:
queries.append(("rare_word_with_paraphrase", variant_query(
base, PARAPHRASE_QUERY[:-1] + f", out by the {decoy[1]}.")))
for label, query in queries:
ranking = asyncio.run(rank_bank(db, adventure, settings, query, embedder.embed))
row = next((r for r in ranking["scored"] if r["memory_id"] == memory_id), None)
variants[label] = {"query": query, "rank": row and row["rank"],
decoy_row = next((r for r in ranking["scored"]
if decoy is not None and r["memory_id"] == decoy[0]), None)
text = query["input"]
variants[label] = {"query": text, "rank": row and row["rank"],
"selected_count": len(ranking["selected"]),
"decoy_memory_id": decoy and decoy[0],
"decoy_rank": decoy_row and decoy_row["rank"],
"decoy_lexical_score": decoy_row and decoy_row["lexical_score"],
"of": len(ranking["scored"]),
"similarity": row and row["similarity"],
"lexical_score": row and row["lexical_score"],
"final_score": row and row["final_score"],
"selected": bool(row and row["selected"]),
"top_k": ranking["top_k"]}
result["ranking_variants"] = variants
+551
View File
@@ -0,0 +1,551 @@
"""v1.1 WP-B.2: how faithfully a real model's memories keep the facts of their block.
**Diagnostic only. Nothing here is imported by the application, and nothing here
is a release gate.**
Why it exists: the first isolation-valid real-model WP-B run failed at creation.
The whole planting block reached the summariser, and the memory it wrote left the
planted fact out (`V1.1-WP-B2-REPORT.md` §L.2). B2.4 tried the plan's bounded
remedy, a memory prompt instructing the model to keep named facts and objects. It
was measured with this module, did not correct the failure, and was **not
shipped** (§T). The module stays so the limitation can be measured again, on this
model or a different one.
- **Fixtures.** Short story blocks, each built around a fact a later scene could
turn on, with the ordinary texture a real block carries around it. They are
genre-neutral (office, contemporary, a science-fiction-neutral station), plus
the attempt-2 planting block itself. Each names the facts a memory must keep,
whom each belongs to, and what it must not invent.
- **The checker** (`evaluate`) is deterministic and reads only the memory text.
It is a heuristic, and says so:
- a fact counts as kept when one sentence names every part of it;
- attribution is the nearest named character before the fact's verb;
- it also reports word count, a leading "Memory:" and second-person "you".
- **The comparison** (`compare`) sends each fixture to a real model through the
application's own provider and `memorybank.memory_user_prompt`. It scores
memories under the shipped prompt and under the rejected B2.4 experiment.
A scripted summariser cannot show what a prompt makes a model do. So the
deterministic tests prove only that the checker is right and the fixtures reach
the summariser; model quality is measured here, with inference, and reported.
# the real-model measurement (inference: ask first)
.venv/bin/python -m tools.memory_fidelity --endpoint <v1 URL> \\
--model qwen2.5:3b-instruct-16k --samples 5 --out "$HOME/v11-evidence/<label>"
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
#: The memory prompt WP-B.2 B2.4 tried and **rejected**. Kept verbatim so the
#: experiment in `V1.1-WP-B2-REPORT.md` §T can be repeated; the application
#: never uses it.
B24_EXPERIMENT_PROMPT = (
"You compress interactive-fiction story excerpts into memories. Respond with "
"1-2 plain sentences in past tense, in at most 50 words.\n\n"
"Keep the concrete facts a later scene could turn on: specific people, "
"objects and places; where something is; who has, hid, found, knows, saw or "
"promised what; injuries, clues and commitments. A distinctive fact comes "
"before mood, scenery, routine movement and small talk. Drop those first, "
"however much of the excerpt they fill, and never let a later passage crowd "
"out an earlier fact.\n\n"
"Keep each fact with the person it belongs to. Never move an action, promise, "
"possession, statement or piece of knowledge from one character to another, "
"and never add a fact the excerpt does not state.\n\n"
'Write in the third person. The narration calls the protagonist "you". The '
'protagonist\'s own actions and words are the lines that begin with ">", '
'written as "I" or "You", and what they establish is part of the story just '
"as the narration is. The protagonist is named in the Cast: refer to them by "
'that name, never as "you" or "I". If the Cast gives no name for them, call '
'them "the player". Name the other characters too rather than writing "he", '
'"she" or "they" on their own — this memory will be read on its own, much '
"later, with nothing around it to say who a pronoun meant.\n\n"
"No preamble, no commentary."
)
TARGET_WORDS = 50
@dataclass(frozen=True)
class Fact:
"""One fact a memory must keep.
`groups`: every group must be matched in one sentence, by any of its terms.
`verbs`: the relation. When one is in that sentence, the nearest named
character before it is who the memory says the fact belongs to.
`actor`: whom it belongs to. `None` for a fact with no owner.
"""
name: str
groups: tuple[tuple[str, ...], ...]
actor: str | None = None
verbs: tuple[str, ...] = ()
@dataclass(frozen=True)
class Fixture:
fixture_id: str
genre: str
requirement: str # which measurement this fixture serves
protagonist: str
others: tuple[str, ...]
actions: tuple[tuple[str, str], ...] # (type, text), oldest first
facts: tuple[Fact, ...]
min_facts: int | None = None # default: all
#: Regexes a faithful memory must not match. Each is anchored on the wrong
#: character as the subject ("Marcus promised"), because a faithful memory
#: may name that character elsewhere in the same sentence ("promised Marcus").
forbidden: tuple[str, ...] = ()
#: Hand-written memories for the checker's own tests: one that should pass,
#: and failures that should not, each with the reason it must report.
faithful: str = ""
unfaithful: tuple[tuple[str, str], ...] = field(default_factory=tuple)
@property
def cast(self) -> tuple[str, ...]:
return (self.protagonist, *self.others)
@property
def raw(self) -> str:
return "\n\n".join(text for _, text in self.actions)
# ------------------------------------------------------------------ checker
def _term(term: str) -> re.Pattern:
body = r"\s+".join(re.escape(part) for part in term.lower().split())
return re.compile(rf"(?<![a-z]){body}(?:s|es|ed|d)?(?![a-z])")
def _sentences(text: str) -> list[str]:
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text or "") if s.strip()]
def _first(low: str, terms) -> int | None:
found = [m.start() for t in terms for m in [_term(t).search(low)] if m]
return min(found) if found else None
def _attributed_to(sentence: str, fact: Fact, cast: tuple[str, ...]) -> str | None:
"""The character this sentence gives the fact to, or None if it names none."""
low = sentence.lower()
at = _first(low, fact.verbs) if fact.verbs else None
names = [(m.start(), name) for name in cast for m in _term(name).finditer(low)]
if at is not None:
before = [(pos, name) for pos, name in names if pos < at]
if before:
return max(before)[1]
return min(names)[1] if names else None
def evaluate(fixture: Fixture, memory: str) -> dict:
"""What a memory kept, whom it gave each fact to, what it invented, and how
it is framed."""
memory = memory or ""
sentences = _sentences(memory)
facts = {}
for fact in fixture.facts:
holding = [s for s in sentences
if all(_first(s.lower(), group) is not None for group in fact.groups)]
owners = sorted({o for s in holding
if (o := _attributed_to(s, fact, fixture.cast)) is not None})
facts[fact.name] = {
"kept": bool(holding),
"attributed_to": owners,
"attribution_ok": (fact.actor is None or not holding
or (owners != [] and set(owners) == {fact.actor})),
}
kept = sum(1 for f in facts.values() if f["kept"])
needed = len(fixture.facts) if fixture.min_facts is None else fixture.min_facts
inventions = [p for p in fixture.forbidden if re.search(p, memory, re.I)]
words = len(memory.split())
misattributed = [name for name, f in facts.items() if f["kept"] and not f["attribution_ok"]]
return {
"facts": facts,
"kept": kept,
"needed": needed,
"retained": kept >= needed,
"misattributed": misattributed,
"inventions": inventions,
"words": words,
"over_target": words > TARGET_WORDS,
# Framing the shipped prompt's rules exist to prevent.
"memory_prefix": memory.lstrip().lower().startswith("memory:"),
"second_person": re.search(r"\byou(r|rs|rself)?\b", memory, re.I) is not None,
"passed": kept >= needed and not misattributed and not inventions,
}
# ----------------------------------------------------------------- fixtures
OFFICE_TEXTURE = (
"The open-plan floor hums with keyboards and the air conditioning rattles in "
"its vent. Someone has left a birthday card on the printer, and the coffee "
"machine gurgles through another pot."
)
FIXTURES: tuple[Fixture, ...] = (
Fixture(
fixture_id="object_place_office",
genre="office",
requirement="distinctive object and place",
protagonist="Dana", others=("Priya",),
actions=(
("start", "Monday at the insurance office. Dana is covering the late shift."),
("do", "> You check the queue of unanswered claims."),
("ai", f"{OFFICE_TEXTURE} Priya walks past your desk carrying a stack of folders, "
"and slides the red backup drive into the bottom drawer of the grey filing "
"cabinet in the archive room before locking it. The phones ring twice and stop. "
"Rain streaks the tall windows while the floor slowly empties."),
("do", "> You ask Priya whether the audit is still on for Thursday."),
("ai", "Priya shrugs, says nobody tells her anything, and goes back to her own desk. "
"The cleaners arrive with their carts and the lights dim on a timer."),
("do", "> You log off and pack your bag."),
),
facts=(Fact("drive in the cabinet", (("backup drive", "drive"), ("drawer", "filing cabinet", "cabinet")),
actor="Priya", verbs=("slid", "slide", "put", "placed", "locked", "hid", "stored", "left")),),
forbidden=(r"\bdana\s+(had\s+)?(took|takes|has|holds|held|stole|locked|slid|hid)\b[^.]*\bdrive\b",),
faithful="Priya locked the red backup drive in the bottom drawer of the grey filing cabinet "
"in the archive room while Dana covered the late shift.",
unfaithful=(
("Dana covered a quiet late shift at the insurance office while rain fell and the "
"cleaners arrived.", "not retained"),
("Dana locked the red backup drive in the bottom drawer of the filing cabinet.",
"misattributed"),
),
),
Fixture(
fixture_id="player_fact_station",
genre="science-fiction-neutral",
requirement="player-established concrete fact",
protagonist="Reyes", others=("Okafor",),
actions=(
("start", "Deck four of the relay station, halfway through the night cycle. Reyes is on maintenance duty."),
("do", "> You walk the corridor checking the pressure seals."),
("ai", "The corridor lights pulse a dim blue. Condensation beads on the pipes, and "
"somewhere below a pump cycles on with a shudder. Chief Okafor passes with a "
"tablet under one arm and nods without stopping."),
("do", "> I watch Chief Okafor seal the coolant sample in locker nine and log it under a false name."),
("ai", "The night cycle drags on. The ventilation hisses, a door chimes somewhere "
"down the ring, and the viewport shows the same slow turn of stars it always "
"does. You finish the seal checks and sign the maintenance sheet, and the "
"corridor settles back into its usual hum."),
("do", "> You head back to your bunk."),
),
facts=(Fact("sample in locker nine", (("coolant sample", "sample"), ("locker",)),
actor="Okafor", verbs=("seal", "sealed", "locked", "put", "stored", "hid", "logged", "placed")),),
forbidden=(r"\breyes\s+(had\s+)?(sealed|seals|hid|stored|locked|logged)\b[^.]*\bsample\b",),
faithful="Reyes saw Chief Okafor seal the coolant sample in locker nine and log it under a false name.",
unfaithful=(
("Reyes finished the seal checks on deck four during a quiet night cycle.", "not retained"),
),
),
Fixture(
fixture_id="promise_contemporary",
genre="contemporary",
requirement="promise / commitment",
protagonist="Dana", others=("Marcus",),
actions=(
("start", "A Saturday afternoon at the flat Dana is about to rent from Marcus."),
("do", "> You look around the empty living room."),
("ai", "Sunlight falls across bare floorboards. The radiator ticks, a neighbour's "
"radio plays through the wall, and Marcus jingles a ring of keys while he "
"talks about the boiler and the bins."),
("do", '> You say "Marcus, I will bring you the signed lease by Friday."'),
("ai", "Marcus nods and writes something on the back of an envelope. Outside a bus "
"pulls away, a dog barks twice, and the afternoon light moves slowly up the wall."),
("do", "> You thank him and leave."),
),
facts=(Fact("lease by Friday", (("lease",), ("friday",)), actor="Dana",
verbs=("promise", "promised", "bring", "agreed", "said", "would")),),
forbidden=(r"\bmarcus\s+(promised|agreed|will\s+bring|would\s+bring)\b[^.]*\blease\b",),
faithful="Dana promised Marcus she would bring him the signed lease for the flat by Friday.",
unfaithful=(
("Marcus promised to bring Dana the signed lease by Friday.", "misattributed"),
("Dana viewed the empty flat on a sunny Saturday while Marcus talked about the boiler.",
"not retained"),
),
),
Fixture(
fixture_id="attribution_station",
genre="science-fiction-neutral",
requirement="attribution",
protagonist="Reyes", others=("Lena", "Tomas"),
actions=(
("start", "The survey ship's cargo bay, between jumps."),
("do", "> You ask who can open the sealed vault."),
("ai", "Lena folds her arms. She is the only one aboard who knows the vault door "
"code, and she makes it clear she is keeping it to herself. Tomas taps the "
"access badge clipped to his jacket; without it the bay lift will not move."),
("do", "> You look from one of them to the other."),
("ai", "The bay lights flicker as the drive spools. Crates creak against their "
"straps, and the air smells of cold metal and oil."),
("do", "> You wait for one of them to speak."),
),
facts=(
Fact("code", (("code",),), actor="Lena", verbs=("knows", "knew", "keeps", "kept", "holds", "held")),
Fact("badge", (("badge",),), actor="Tomas",
verbs=("carries", "carried", "has", "had", "holds", "held", "wore", "wears", "tapped", "taps")),
),
forbidden=(r"\breyes\b[^.]*\b(knew|knows)\b[^.]*\bcode\b",),
faithful="Lena alone knew the vault door code and kept it to herself; Tomas carried the access "
"badge that the bay lift needed.",
unfaithful=(
("Tomas knew the vault door code, and Lena carried the access badge.", "misattributed"),
),
),
Fixture(
fixture_id="clutter_office",
genre="office",
requirement="clutter pressure",
protagonist="Dana", others=("Priya", "Owen"),
actions=(
("start", "The quarterly offsite at a conference hotel by the motorway."),
("do", "> You find a seat near the back."),
("ai", "The conference room smells of carpet cleaner and burnt coffee. Chairs scrape, "
"a projector fan whines, and someone at the front struggles with the clicker. "
"Owen talks about his weekend at length, the traffic on the ring road, a new "
"sandwich place, the football, and whether it will rain for the barbecue. The "
"slides cycle through charts nobody reads. Outside the window lorries hiss past "
"on the wet motorway, and the hotel's muzak drifts in whenever the door opens."),
("do", "> You go to the refreshment table."),
("ai", "Pastries sweat under cling film. Priya stirs her tea, glances around, and "
"quietly tells you that she saw Owen shred the signed supplier contract in the "
"copy room last night. Then she talks about the weather, the parking, and the "
"long drive home, and laughs at a joke from across the room. The afternoon "
"session is announced, people drift back to their seats, and the projector "
"fan starts whining again over a long talk about quarterly targets."),
("do", "> You take your seat for the afternoon session."),
),
facts=(Fact("contract shredded", (("contract",), ("shred", "shredded", "destroyed")),
actor="Owen", verbs=("shred", "shredded", "destroyed")),),
forbidden=(r"\b(priya|dana)\b\s+(had\s+)?(shred|shredded|destroyed)\b",),
faithful="At the offsite, Priya told Dana she had seen Owen shred the signed supplier contract "
"in the copy room the night before.",
unfaithful=(
("Dana sat through a dull offsite of charts, pastries and Owen's talk about the weekend.",
"not retained"),
("Priya shredded the signed supplier contract in the copy room.", "misattributed"),
),
),
Fixture(
fixture_id="no_invention_office",
genre="office",
requirement="no invention",
protagonist="Dana", others=("Owen",),
actions=(
("start", "A short planning meeting in the small room on the third floor."),
("do", "> You sit down opposite Owen."),
("ai", "A black briefcase sits unclaimed by the door; nobody mentions it. Owen says "
"the budget review has moved from Tuesday to Thursday, and asks you to tell "
"the team."),
("do", "> You agree to pass it on."),
("ai", "Owen thanks you, checks his phone, and the meeting ends after ten minutes. "
"The briefcase is still by the door when you leave."),
("do", "> You walk back to your desk."),
),
facts=(Fact("review moved", (("budget review", "review"), ("thursday",))),),
forbidden=(
r"\b(took|taken|stole|hid|hidden|grabbed|pocketed|carried|carries|owns|owned|belong\w*)\b[^.]*\bbriefcase\b",
r"\bbriefcase\b[^.]*\b(belong\w*|his|her|owen's|dana's|secret|clue)\b",
r"\b(clue|secret|password|code)\b",
),
faithful="Owen told Dana the budget review had moved from Tuesday to Thursday, and Dana agreed "
"to tell the team.",
unfaithful=(
("Owen told Dana the budget review had moved to Thursday and left his secret briefcase by the door.",
"invented"),
),
),
Fixture(
fixture_id="multiple_facts_station",
genre="science-fiction-neutral",
requirement="multiple concrete facts",
protagonist="Reyes", others=("Hale", "Varga", "Moreau"),
actions=(
("start", "The mess hall of the mining outpost after the shift change."),
("do", "> You sit with the day crew."),
("ai", "Trays clatter and the recycler drones. Engineer Hale admits, half joking, that "
"she hid the spare fuse inside the airlock control panel. Doctor Varga says only "
"she knows the reactor override phrase, and changes the subject. Pilot Moreau "
"grumbles that he owes Hale two shifts of cover."),
("do", "> You finish your meal."),
("ai", "The lights dim for the rest cycle and the crew drifts off to their bunks."),
("do", "> You head to your quarters."),
),
facts=(
Fact("fuse in the panel", (("fuse",), ("panel", "airlock")), actor="Hale",
verbs=("hid", "hidden", "put", "stashed", "kept")),
Fact("override phrase", (("override",),), actor="Varga", verbs=("knows", "knew", "keeps")),
Fact("owes cover", (("owes", "owed", "debt"), ("shift", "cover")), actor="Moreau",
verbs=("owes", "owed")),
),
min_facts=2,
faithful="Hale hid the spare fuse inside the airlock control panel, and Varga alone knew the "
"reactor override phrase.",
unfaithful=(
("Varga hid the spare fuse inside the airlock panel, and Hale knew the reactor override phrase.",
"misattributed"),
),
),
Fixture(
# The planting block of WP-B.2 real-model attempt 2, verbatim: the
# harness's own fixture campaign, with no identifier in it. It is the
# failure the reference model has not overcome.
fixture_id="regression_attempt_2",
genre="fantasy (the harness campaign)",
requirement="the actual failed-run block",
protagonist="Aldric", others=("Mara", "Edrin"),
actions=(
("start", "Rain over Westhaven. Aldric sits in the Crooked Lantern with a silver key in his "
"pocket and no-one to give it to."),
("do", "> I tell Mara quietly that the silver key opens the crypt beneath the Old Abbey — "
"SILVER-KEY-CRYPT-OLD-ABBEY."),
("ai", "The lantern's warmth feels cold against Aldric's hands as he fidgets with the silver "
"key. Mara observes him without a word, her eyes unreadable. The rain outside "
"persists, a backdrop of nature's indifference to the human drama transpiring. "
"Aldric's fingers close over the key, the familiar weight making him feel both more "
"alone and more determined. He pulls the key from his pocket, the metal feeling cold "
"against his skin. \n\n> \"I have the key,\" Aldric whispers to Mara. \"It leads to "
"the crypt beneath the abbey.\"\n\nEdrin, having finished his ale, stands. \"Well, now "
"that we know the key leads to somewhere... do you think we should see what it "
"opens?\"\n\nThe silver key feels heavy in Aldric's hands. He looks at Mara, then at "
"the key, and finally at Edrin. He decides. \"Let's do this.\""),
("do", "> I watch Mara slip the amber sundial inside the cracked teapot on the tavern's top "
"shelf, and she makes me promise to tell no one."),
("ai", "Aldric steps closer to the smoky bar, his gaze shifting from the silver key in his "
"pocket to Mara. \"Let's not overthink this,\" he says, his voice steady despite the "
"gnawing doubt that tugs at him. \"Let's see what the crypt holds.\"\n\nEdrin raises "
"an eyebrow, his curiosity piqued. \"Alright, then. But if this leads somewhere "
"dangerous, we're splitting up. If we make it back here, I'll tell no one what we "
"found.\"\n\nMara nods her agreement, her eyes still unreadable. \"Agreed. But "
"remember, we're not the first to look there. We need to tread carefully.\"\n\nWith a "
"slight nod, they step out of the tavern. The rain has only intensified, pelting them "
"as they walk toward the Old Abbey's shadowed grounds. The crypt, sealed since its "
"founding, stands before them, its door ajar, inviting or perhaps foreboding. As "
"Aldric approaches, he feels a strange mixture of excitement and fear, his hands "
"curling around the silver key's handle. \n\n> He turns the key, and the lock clicks "
"open with a satisfying, if ominous, hiss. The door swings inward, revealing a narrow "
"passageway. The air inside is musty and cold, the only light filtering in through "
"the cracks. \n\nAldric takes a tentative step inside, his breath visible in the air. "
"Mara and Edrin follow, their footsteps echoing in the silence. The walls are lined "
"with stone and cobwebs, and the smell of damp and decay is overwhelming. They "
"advance cautiously, each of them aware of the potential dangers lurking within."),
("do", "> I ask Mara what she has heard about the abbey."),
),
facts=(Fact("sundial in the teapot", (("sundial",), ("teapot",)), actor="Mara",
verbs=("slip", "slipped", "hid", "hidden", "put", "placed", "tucked", "stashed")),),
forbidden=(
r"\b(aldric|edrin)\b\s+(had\s+)?(slipped|hid|placed|put|tucked|stashed)\b[^.]*\bsundial\b",
),
faithful="Aldric watched Mara slip the amber sundial inside the cracked teapot on the tavern's "
"top shelf and promised her to tell no one; then Aldric, Mara and Edrin entered the "
"crypt beneath the Old Abbey.",
unfaithful=(
# The memory attempt 2 actually stored, verbatim.
("Aldric sits in the Crooked Lantern with a silver key in his pocket, no-one to give it to. "
"Mara observed him quietly, her eyes unreadable. Edrin finished his ale and stood, asking if "
"they should see what the crypt beneath the Old Abbey holds. Aldric decided to go, promising "
"not to tell anyone. They walked to the Old Abbey's grounds, the crypt door ajar, inviting "
"and foreboding. Inside, the air was musty and cold, with the smell of damp and decay. They "
"advanced cautiously, each aware of potential dangers. The silver key, the key to the crypt, "
"felt heavy in Aldric's hands.", "not retained"),
("Aldric slipped the amber sundial inside the cracked teapot on the tavern's top shelf.",
"misattributed"),
),
),
)
FIXTURES_BY_ID = {f.fixture_id: f for f in FIXTURES}
def cast_brief_for(fixture: Fixture) -> str:
"""The cast brief `memorybank.cast_brief` would build for this fixture."""
from app import memorybank
lines = [memorybank._cast_line(fixture.protagonist, "", protagonist=True)]
lines += [memorybank._cast_line(name, "") for name in fixture.others]
return "Cast:\n" + "\n".join(lines)
def user_prompt_for(fixture: Fixture) -> str:
"""Exactly the user message the application sends for this block."""
from app import memorybank
return memorybank.memory_user_prompt(cast_brief_for(fixture), memorybank.memory_excerpt(fixture.raw))
# --------------------------------------------------------- the measurement
async def compare(endpoint: str, model: str, samples: int) -> dict:
"""Every fixture, `samples` times, under the shipped prompt and the B2.4 experiment."""
from app import memorybank, models
settings = models.Settings(endpoint_url=endpoint, model=model, summary_model="",
api_mode=models.Settings.__table__.c.api_mode.default.arg,
model_timeout_seconds=300)
provider = memorybank.summary_provider(settings)
arms = {"shipped": memorybank.MEMORY_SYSTEM_PROMPT, "b2.4-experiment": B24_EXPERIMENT_PROMPT}
out: dict = {"endpoint_model": model, "samples": samples, "fixtures": {}}
for fixture in FIXTURES:
user = user_prompt_for(fixture)
row: dict = {"requirement": fixture.requirement, "genre": fixture.genre, "arms": {}}
for arm, system in arms.items():
runs = []
for _ in range(samples):
text = (await provider.complete(system, user) or "").strip()
runs.append({"memory": text, **evaluate(fixture, text)})
summary = {
"passed": sum(r["passed"] for r in runs),
"retained": sum(r["retained"] for r in runs),
"misattributed": sum(bool(r["misattributed"]) for r in runs),
"invented": sum(bool(r["inventions"]) for r in runs),
"memory_prefix": sum(r["memory_prefix"] for r in runs),
"second_person": sum(r["second_person"] for r in runs),
"words_median": sorted(r["words"] for r in runs)[len(runs) // 2],
"words_max": max(r["words"] for r in runs),
"over_target": sum(r["over_target"] for r in runs),
}
row["arms"][arm] = {"runs": runs, **summary}
print(f"{fixture.fixture_id:28} {arm:16} "
+ " ".join(f"{k} {v}" for k, v in summary.items()), flush=True)
out["fixtures"][fixture.fixture_id] = row
return out
def main() -> int:
import argparse
import asyncio
import json
import os
import sys
import tempfile
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--endpoint", required=True)
parser.add_argument("--model", required=True)
parser.add_argument("--samples", type=int, default=5)
parser.add_argument("--out", required=True)
args = parser.parse_args()
handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
handle.close()
os.environ["AIDND_DB_PATH"] = handle.name # nothing is written; never the real database
os.environ.pop("AIDND_DATABASE_URL", None)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
try:
result = asyncio.run(compare(args.endpoint, args.model, args.samples))
finally:
Path(handle.name).unlink(missing_ok=True)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
(out / "fidelity.json").write_text(json.dumps(result, indent=2))
print(f"written to {out / 'fidelity.json'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -3
View File
@@ -103,13 +103,16 @@ def main() -> int:
variants = {}
memory_id = diagnosis["created"]["memory_id"]
if memory_id is not None and not diagnosis.get("retained", {}).get("forgotten"):
for label, query in (("recall_turn", diagnosis.get("ranked", {}).get("query", "")),
("paraphrase", md.PARAPHRASE_QUERY),
("unrelated", md.UNRELATED_QUERY)):
base = md.production_query(adventure, recall_action.id)
for label, query in (("recall_turn", base),
("paraphrase", md.variant_query(base, md.PARAPHRASE_QUERY)),
("unrelated", md.variant_query(base, md.UNRELATED_QUERY))):
ranking = asyncio.run(md.rank_bank(db, adventure, settings, query, embed))
row = next((r for r in ranking["scored"] if r["memory_id"] == memory_id), None)
variants[label] = {"rank": row and row["rank"], "of": len(ranking["scored"]),
"similarity": row and row["similarity"],
"lexical_score": row and row["lexical_score"],
"final_score": row and row["final_score"],
"selected": bool(row and row["selected"])}
db.rollback()
report = {"isolation": iso, "diagnosis": diagnosis, "ranking_variants": variants}