M6: branch-safe context, summaries and long-term story memory
Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.
This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.
What was already correct, and was kept rather than rebuilt
Memory lineage. Memories already carried (branch_id, depth) and retrieval
already filtered through the capped-path clause; the ten-step negative control
was measured passing against b7005e6 before any change here. M6 adds the
regression tests that pin it, plus provenance and authority on the result.
Summary lineage — both halves
A summary is a row carrying the coordinate of the last node it covers, and
eligibility is the same head-capped lineage clause memories use. That alone
was not enough: generation was seeded from adventures.story_summary, a
campaign-global column with no lineage, so after a divergence the summariser
was handed the abandoned line's prose and asked to update it. The row it
produced was correctly anchored and therefore looked safe while its sentences
described a story the reader had left.
Generation is now seeded from summaries.current — the same question the
context builder asks — so the input and the output are scoped by one rule.
adventures.story_summary remains a reader-facing mirror for the Plot panel and
the export bundle, kept in step when a summary is written and when the head
moves, and nothing authoritative reads it.
Retrieval redundancy
With a real embedding model, four near-identical memories crowded out the one
distinctive clue, which survived only because the default memory_top_k is 5.
Retrieval now drops a candidate that repeats one already chosen, never across
authority classes, at a threshold measured against the configured embedding
model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
and are recorded as such.
Memory authority, budgeting, observability
Memory.authority is accepted_story or heuristic, classified by the application
and marked in the prompt; retrieval never writes state. The reply is reserved
out of the context budget, and an impossible configuration fails clearly
instead of overflowing. Each derived pass records ok/idle/failed per campaign,
served by GET /adventures/{id}/derived and shown in Insights, so the M2
failure — a dead memory bank with a green suite — is visible if it recurs.
Provider-wiring tests mock no factory.
Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.
Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
co-authored by
Claude Opus 5
parent
b7005e6fdd
commit
a6e9c7a32b
+246
-40
@@ -27,13 +27,14 @@ succeeds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from array import array
|
||||
from collections import OrderedDict
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from . import models, tree, vectors
|
||||
from . import derived, models, summaries, tree, vectors
|
||||
from .context import (
|
||||
cursors,
|
||||
history,
|
||||
@@ -46,6 +47,8 @@ from .database import SessionLocal
|
||||
from .providers import OpenAICompatibleProvider, ProviderError
|
||||
from .vectors import cosine # re-exported: the ranking lives here, the maths there
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MEMORY_INTERVAL = 6 # actions per memory
|
||||
MEMORY_START = 12 # first memory once the adventure reaches this many actions
|
||||
SUMMARY_INTERVAL = 15 # actions between Story Summary updates
|
||||
@@ -146,6 +149,104 @@ _tasks: set[asyncio.Task] = set()
|
||||
# They used to also read an API key, which is gone: Ollama does not use one and
|
||||
# M2 removed cloud providers. `summary_model` and `embedding_model` fall back to
|
||||
# the narrator model when the user has not named a separate one.
|
||||
# M6: the words that mark a memory as an interpretation rather than a record.
|
||||
#
|
||||
# The application owns this classification, not the model
|
||||
# (`CONTEXT-AND-MEMORY.md` §14, §15). The extractor writes prose; this decides
|
||||
# what weight the narrator is told to give it. The list is deliberately short
|
||||
# and readable: a memory that hedges is a reading of the story, not a fact the
|
||||
# story established, and the narrator must not be able to promote it to canon.
|
||||
#
|
||||
# Being wrong in the cautious direction is cheap — a hedged record labelled
|
||||
# heuristic is still retrieved and still useful. Being wrong the other way is
|
||||
# what turns a guess into canon, which is the failure §14 exists to prevent.
|
||||
HEURISTIC_MARKERS = (
|
||||
"seemed", "seems", "appeared to", "appears to", "apparently", "perhaps",
|
||||
"maybe", "might have", "may have", "possibly", "presumably", "suggested that",
|
||||
"suggests that", "implied", "implies", "as if", "likely", "probably",
|
||||
"seemingly", "hinted", "hints that", "suspects", "suspected", "believes",
|
||||
"believed", "wondered whether", "wonders whether",
|
||||
)
|
||||
|
||||
ACCEPTED_STORY = "accepted_story"
|
||||
HEURISTIC = "heuristic"
|
||||
|
||||
|
||||
# M6 corrective (review finding M6-F2). How close two memories have to be before
|
||||
# the second one is treated as saying nothing new.
|
||||
#
|
||||
# The value is measured, not guessed. Against the configured local embedding
|
||||
# model, on a fixture of near-identical "the party walks the muddy road"
|
||||
# memories and a set of genuinely distinct ones:
|
||||
#
|
||||
# redundant pairs cosine 0.938 - 0.996
|
||||
# distinct pairs cosine 0.349 - 0.906
|
||||
#
|
||||
# 0.93 sits in that gap. The same measurement ruled out the more obvious
|
||||
# lexical test: word overlap fires hardest on exactly the pair that must NOT be
|
||||
# merged — "Mara promised to return before dawn" against "Aldric promised to
|
||||
# return before dawn" shares 71% of its words while meaning something else —
|
||||
# and is weakest (27%) on filler that plainly repeats itself. Wording is a poor
|
||||
# proxy for sameness of fact; the embedding is a better one.
|
||||
#
|
||||
# The threshold is model-dependent by nature. A different embedding model may
|
||||
# need a different number, which is why the measurement is written down here
|
||||
# rather than the value alone.
|
||||
REDUNDANT_SIMILARITY = 0.93
|
||||
|
||||
|
||||
def _drop_redundant(candidates, vectors, authority_of, limit):
|
||||
"""Fills `limit` slots, skipping memories that repeat one already chosen.
|
||||
|
||||
Greedy over the ranked list, so the highest-scoring statement of a fact is
|
||||
the one kept and its provenance is the provenance that survives. Two rules
|
||||
keep this from losing information:
|
||||
|
||||
* **Authority is never crossed.** An inference and a record are different
|
||||
kinds of claim even when they read alike, so a `heuristic` memory can
|
||||
never suppress an `accepted_story` one or the reverse.
|
||||
* **The bar is high.** Missing a duplicate costs some budget; dropping a
|
||||
distinct fact costs the narrator something it needed. The threshold is
|
||||
set where the measurement says distinct facts stop appearing.
|
||||
|
||||
Returns `(kept, suppressed)`, the second for the inspector — a reader
|
||||
should be able to see that memories were considered and set aside rather
|
||||
than never retrieved.
|
||||
"""
|
||||
kept: list = []
|
||||
suppressed: list = []
|
||||
for row in candidates:
|
||||
if len(kept) >= limit:
|
||||
break
|
||||
_score, memory_id, _pinned = row
|
||||
vector = vectors.get(memory_id)
|
||||
duplicate_of = None
|
||||
if vector is not None:
|
||||
for _kept_score, kept_id, _ in kept:
|
||||
if authority_of.get(kept_id) != authority_of.get(memory_id):
|
||||
continue
|
||||
other = vectors.get(kept_id)
|
||||
if other is not None and cosine(vector, other) >= REDUNDANT_SIMILARITY:
|
||||
duplicate_of = kept_id
|
||||
break
|
||||
if duplicate_of is None:
|
||||
kept.append(row)
|
||||
else:
|
||||
suppressed.append((memory_id, duplicate_of))
|
||||
return kept, suppressed
|
||||
|
||||
|
||||
def classify_authority(text: str) -> str:
|
||||
"""Returns `accepted_story` or `heuristic` for one memory's text.
|
||||
|
||||
Hedged language is the signal. "Aldric promised Mara he would return before
|
||||
dawn" is something the story established; "Mara seemed uneasy when Captain
|
||||
Vale was mentioned" is an inference about it, and the prompt has to say so.
|
||||
"""
|
||||
lowered = text.lower()
|
||||
return HEURISTIC if any(m in lowered for m in HEURISTIC_MARKERS) else ACCEPTED_STORY
|
||||
|
||||
|
||||
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url,
|
||||
@@ -473,7 +574,7 @@ async def retrieve_memories(
|
||||
# affordable because memories are sparse, at roughly one per six actions, so
|
||||
# even a heavily forked story returns only tens of small rows.
|
||||
catalogue = db.execute(
|
||||
select(models.Memory.id, models.Memory.pinned).where(
|
||||
select(models.Memory.id, models.Memory.pinned, models.Memory.authority).where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
models.Memory.forgotten.is_(False),
|
||||
@@ -495,11 +596,12 @@ async def retrieve_memories(
|
||||
except ProviderError as exc:
|
||||
return {"used": [], "error": str(exc)}
|
||||
|
||||
held = _vectors_for(db, adventure.id, [memory_id for memory_id, _ in catalogue])
|
||||
held = _vectors_for(db, adventure.id, [memory_id for memory_id, _, _ in catalogue])
|
||||
authority_of = {memory_id: authority for memory_id, _, authority in catalogue}
|
||||
scored = sorted(
|
||||
(
|
||||
(cosine(query_vec, held[memory_id]), memory_id, pinned)
|
||||
for memory_id, pinned in catalogue
|
||||
for memory_id, pinned, _ in catalogue
|
||||
if memory_id in held
|
||||
),
|
||||
key=lambda row: row[0],
|
||||
@@ -511,19 +613,31 @@ async def retrieve_memories(
|
||||
top_k = max(1, settings.memory_top_k)
|
||||
used = [row for row in scored if row[2]]
|
||||
remaining = max(0, top_k - len(used))
|
||||
used += [row for row in scored if not row[2]][:remaining]
|
||||
candidates = [row for row in scored if not row[2]]
|
||||
kept, suppressed = _drop_redundant(candidates, held, authority_of, remaining)
|
||||
used += kept
|
||||
used.sort(key=lambda row: row[0], reverse=True)
|
||||
if not used:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Fetch the text only now, and only for the `top_k` rows that were chosen.
|
||||
#
|
||||
# M6 adds authority and provenance to this same read rather than to a second
|
||||
# one. The columns are narrow, the row set is `top_k`, and fetching them
|
||||
# here is what keeps "why did the narrator remember this?" answerable
|
||||
# without a query per memory (F06, and the N+1 discipline M5 restored).
|
||||
used_ids = [memory_id for _, memory_id, _ in used]
|
||||
texts = dict(
|
||||
db.execute(
|
||||
select(models.Memory.id, models.Memory.text)
|
||||
.where(models.Memory.id.in_(used_ids))
|
||||
detail = {
|
||||
row.id: row
|
||||
for row in db.execute(
|
||||
select(
|
||||
models.Memory.id, models.Memory.text, models.Memory.authority,
|
||||
models.Memory.branch_id, models.Memory.depth,
|
||||
models.Memory.source_start, models.Memory.source_end,
|
||||
).where(models.Memory.id.in_(used_ids))
|
||||
).all()
|
||||
)
|
||||
}
|
||||
texts = {memory_id: row.text for memory_id, row in detail.items()}
|
||||
|
||||
if update_stats:
|
||||
# Pass `synchronize_session=False` because nothing in this request
|
||||
@@ -539,10 +653,29 @@ async def retrieve_memories(
|
||||
|
||||
return {
|
||||
"used": [
|
||||
{"id": memory_id, "text": texts.get(memory_id, ""),
|
||||
"similarity": round(score, 4), "pinned": pinned}
|
||||
{
|
||||
"id": memory_id,
|
||||
"text": texts.get(memory_id, ""),
|
||||
"similarity": round(score, 4),
|
||||
"pinned": pinned,
|
||||
# M6: what weight this carries, and where it came from.
|
||||
"authority": getattr(detail.get(memory_id), "authority", ACCEPTED_STORY),
|
||||
"source": {
|
||||
"branch_id": getattr(detail.get(memory_id), "branch_id", None),
|
||||
"depth": getattr(detail.get(memory_id), "depth", None),
|
||||
"source_start": getattr(detail.get(memory_id), "source_start", None),
|
||||
"source_end": getattr(detail.get(memory_id), "source_end", None),
|
||||
},
|
||||
}
|
||||
for score, memory_id, pinned in used
|
||||
],
|
||||
"considered": len(catalogue),
|
||||
# M6: how many candidates were set aside as repeating one already
|
||||
# chosen. Visible so that "why is that memory not here?" has an answer.
|
||||
"suppressed": [
|
||||
{"id": memory_id, "duplicate_of": kept_id}
|
||||
for memory_id, kept_id in suppressed
|
||||
],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
@@ -587,17 +720,55 @@ async def run_post_turn(adventure_id: int) -> None:
|
||||
# An anchor past the tip is not an invalid value. `settled_after`
|
||||
# reports that there is nothing to do, and once the story grows past the
|
||||
# anchor the pass resumes where it stopped.
|
||||
# M6. Each kind runs inside its own recorder, so one failing pass
|
||||
# neither hides the others nor takes the turn down with it. The accepted
|
||||
# narration, its state events, the authoritative document and the head
|
||||
# were all committed before this task started; nothing here may undo
|
||||
# them, and nothing here may fail without leaving a record
|
||||
# (`BUILD-MILESTONES.md`, note from M2).
|
||||
if adventure.auto_summarize:
|
||||
await _create_due_memories(adventure, settings, db)
|
||||
await _update_story_summary(adventure, settings, db)
|
||||
await _guarded(db, adventure_id, derived.MEMORY,
|
||||
_create_due_memories(adventure, settings, db))
|
||||
await _guarded(db, adventure_id, derived.SUMMARY,
|
||||
_update_story_summary(adventure, settings, db))
|
||||
if adventure.memory_bank_enabled and settings.embedding_model.strip():
|
||||
await _embed_pending(adventure, settings, db)
|
||||
await _guarded(db, adventure_id, derived.EMBEDDING,
|
||||
_embed_pending(adventure, settings, db))
|
||||
_evict_over_capacity(adventure, settings, db)
|
||||
except BaseException as exc: # noqa: BLE001 - the task boundary
|
||||
# Anything the per-kind guards did not catch: a failure in the shared
|
||||
# setup above, or in eviction. M2's lesson is that the one thing this
|
||||
# may not do is vanish. Re-raising would only feed an unobserved task.
|
||||
try:
|
||||
derived.failed(db, adventure_id, derived.MEMORY, exc)
|
||||
db.commit()
|
||||
except BaseException: # noqa: BLE001 - the recorder must not mask it
|
||||
log.exception("could not record derived-work failure for %s", adventure_id)
|
||||
finally:
|
||||
db.close()
|
||||
_running.discard(adventure_id)
|
||||
|
||||
|
||||
async def _guarded(db: Session, adventure_id: int, kind: str, coro) -> None:
|
||||
"""Runs one derived pass, recording whether it worked.
|
||||
|
||||
The pass keeps whatever it committed before it failed — a memory written
|
||||
two blocks ago stays written — because derived work is additive and
|
||||
partial progress is still progress. What must not survive is an
|
||||
uncommitted, half-written unit of work, so the session is rolled back to
|
||||
the last commit before the failure is recorded.
|
||||
"""
|
||||
try:
|
||||
did_work = await coro
|
||||
except BaseException as exc: # noqa: BLE001 - one kind must not stop another
|
||||
db.rollback()
|
||||
derived.failed(db, adventure_id, kind, exc)
|
||||
db.commit()
|
||||
else:
|
||||
derived.succeeded(db, adventure_id, kind, did_work=bool(did_work))
|
||||
db.commit()
|
||||
|
||||
|
||||
async def summarize_block(
|
||||
adventure: models.Adventure,
|
||||
provider: OpenAICompatibleProvider,
|
||||
@@ -629,37 +800,39 @@ async def summarize_block(
|
||||
|
||||
async def _create_due_memories(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
) -> int:
|
||||
"""Writes the memories that are due. Returns how many it wrote (M6-F5)."""
|
||||
provider = summary_provider(settings)
|
||||
written = 0
|
||||
for _ in range(MAX_MEMORIES_PER_RUN):
|
||||
# Re-read the anchor on every pass. Committing a memory does not change
|
||||
# the story, but this loop is the only code that moves the anchor, so
|
||||
# both numbers must be current.
|
||||
anchor = cursors.MEMORY.depth(db, adventure)
|
||||
if history.count_after(adventure, anchor) < MEMORY_INTERVAL + SETTLE_SLACK:
|
||||
return # No settled block of story sits past the mark. The block
|
||||
return written # No settled block of story sits past the mark. The block
|
||||
# itself is still MEMORY_INTERVAL actions; the slack asks
|
||||
# for story past its end. See `SETTLE_SLACK`.
|
||||
if history.count(adventure) < MEMORY_START:
|
||||
return # The adventure is too short to have started summarizing.
|
||||
return written # The adventure is too short to have started summarizing.
|
||||
# The order of those two checks is deliberate. The usual answer is that
|
||||
# no memory is due, and the first check settles that without measuring
|
||||
# the length of the whole story.
|
||||
block = history.after(adventure, anchor, MEMORY_INTERVAL)
|
||||
if len(block) < MEMORY_INTERVAL:
|
||||
return
|
||||
try:
|
||||
text = await summarize_block(adventure, provider, block)
|
||||
except ProviderError:
|
||||
return # Logged on the debug page. The cursor is unchanged, so the
|
||||
# next turn retries this block.
|
||||
return written
|
||||
# A provider failure is no longer caught here. `_guarded` records it
|
||||
# against this campaign, and the cursor is unchanged either way, so the
|
||||
# next accepted turn retries this same block (M6).
|
||||
text = await summarize_block(adventure, provider, block)
|
||||
if not text:
|
||||
return
|
||||
return written
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id,
|
||||
text=text,
|
||||
source_start=block[0].depth,
|
||||
source_end=block[-1].depth,
|
||||
authority=classify_authority(text),
|
||||
)
|
||||
# Attach the memory to the node it summarizes, so that a fork inherits
|
||||
# the memories of the path it forked from and no others. Then move the
|
||||
@@ -670,22 +843,27 @@ async def _create_due_memories(
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, block[-1])
|
||||
db.commit()
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
async def _update_story_summary(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Rolls the summary forward when enough new story has settled.
|
||||
|
||||
Returns whether it wrote one (M6-F5)."""
|
||||
anchor = cursors.SUMMARY.depth(db, adventure)
|
||||
uncovered = history.count_after(adventure, anchor)
|
||||
if uncovered < SUMMARY_INTERVAL:
|
||||
return
|
||||
return False
|
||||
# Where the summary stands once this run succeeds. Read this before the AI
|
||||
# call rather than after it. The mark records the end of the story as this
|
||||
# pass saw it, and a turn that arrives during the call must not be counted
|
||||
# as read.
|
||||
caught_up = history.newest(adventure)
|
||||
if caught_up is None:
|
||||
return
|
||||
return False
|
||||
|
||||
# Include the memories for the stretch that the summary has not read, which
|
||||
# means every memory attached to a node past the anchor. The marks and the
|
||||
@@ -707,7 +885,25 @@ async def _update_story_summary(
|
||||
block = history.after(adventure, anchor, uncovered)
|
||||
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
|
||||
|
||||
current = adventure.story_summary.strip()
|
||||
# M6 corrective (review finding M6-F1). The previous summary this one
|
||||
# builds on has to be a summary that is *valid where the story now stands*,
|
||||
# not merely the last one written.
|
||||
#
|
||||
# Seeding from `adventure.story_summary` — a campaign-global column with no
|
||||
# lineage — is what broke E03. After a divergence that column still held the
|
||||
# abandoned line's prose, so the summariser was handed it and asked to
|
||||
# update it. The row it produced was correctly anchored to the new branch
|
||||
# and was therefore *reported* as lineage-safe, while its sentences
|
||||
# described a story the reader had left. The row was anchored; the content
|
||||
# was not.
|
||||
#
|
||||
# `summaries.current` answers the same question the context builder asks —
|
||||
# which summary is eligible at the head — so the input and the output are
|
||||
# now scoped by one rule. Where no eligible summary exists, the new line
|
||||
# starts from nothing, which is the truthful starting point for a story
|
||||
# that has not been summarised yet.
|
||||
eligible = summaries.current(db, adventure)
|
||||
current = eligible.text.strip() if eligible is not None else ""
|
||||
# The summary is built from the memories, so it inherits their framing for
|
||||
# free once they are named and third-person. It still gets the brief of its
|
||||
# own, because the fallback above hands it raw second-person story text
|
||||
@@ -720,22 +916,31 @@ async def _update_story_summary(
|
||||
)
|
||||
if brief:
|
||||
user_prompt = f"{brief}\n\n{user_prompt}"
|
||||
try:
|
||||
text = await summary_provider(settings).complete(
|
||||
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
|
||||
)
|
||||
except ProviderError:
|
||||
return
|
||||
text = await summary_provider(settings).complete(
|
||||
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
|
||||
)
|
||||
if not text:
|
||||
return
|
||||
adventure.story_summary = text
|
||||
return False
|
||||
# M6: anchored to the story it summarizes rather than written into a single
|
||||
# column. `caught_up` is the last node it covers, so the row is eligible on
|
||||
# exactly the lineages that contain that node, and an Undo or a divergence
|
||||
# makes it ineligible without deleting it (E03, `summaries` module).
|
||||
summaries.record(
|
||||
db, adventure, text,
|
||||
node=caught_up,
|
||||
source_start=anchor + 1 if anchor is not None else None,
|
||||
trigger="interval",
|
||||
model_name=(settings.summary_model or settings.model or ""),
|
||||
)
|
||||
cursors.SUMMARY.anchor_at(adventure, caught_up)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def _embed_pending(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
) -> int:
|
||||
"""Embeds memories that have no vector. Returns how many (M6-F5)."""
|
||||
# Use a query rather than walking `adventure.memories`. That walk ran on
|
||||
# every turn and loaded the whole bank's vectors in order to find the few
|
||||
# rows with none.
|
||||
@@ -759,14 +964,15 @@ async def _embed_pending(
|
||||
.all()
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
return 0
|
||||
try:
|
||||
new = await embedding_provider(settings).embed([m.text for m in pending])
|
||||
except ProviderError:
|
||||
return
|
||||
return 0
|
||||
for memory, vector in zip(pending, new):
|
||||
set_vector(memory, vector)
|
||||
db.commit()
|
||||
return len(pending)
|
||||
|
||||
|
||||
def _evict_over_capacity(
|
||||
|
||||
Reference in New Issue
Block a user