Перейти к содержанию

Bounded recall из ledger (experimental)

protoprompt.ledger.recall — первый read-путь от долговременной Ledger-памяти к текущей задаче агента. Компонент намеренно небольшой и локальный:

  • читает только active, подтверждённые host-ом, ещё валидные записи с payload из одного pinned MemoryWriter;
  • ранжирует локально и детерминированно по лексическому соответствию, confidence и свежести — без LLM, embeddings, vector query, сети и legacy API;
  • упаковывает записи целиком в фиксированные token и UTF-8 byte budgets, включая полный JSON envelope;
  • выполняет финальную проверку выбранных record ID и ревизий непосредственно перед возвратом контекста. Забытая, отозванная, истёкшая, удалённая или изменённая запись делает resolution fail-closed: нужен новый план.

Это не обещание бесконечного контекстного окна. Это ограниченный memory data lane. Standalone planner оставляет его отдельными данными; для host-а, которому нужен точный provider request, v0.11 добавляет явный experimental LedgerContextComposer.

v0.12 добавляет намеренно узкую durable continuation boundary: sealed recall checkpoint переживает restart процесса, но не является checkpoint-ом агента и не обещает бесконечную память. Он сохраняет проверенный выбор recall, а не provider conversation или workflow state.

Для concrete v5 ingress origin active reader проверяет парный immutable allow audit до попадания record в этот lane. Записи, мигрированные из схемы до v5, получают legacy_unknown и остаются recallable только ради совместимости; strict deployment должен quarantine-ить и re-admit-ить их до включения recall. Raw unknown writer records тоже являются trusted legacy escape hatch, а не provenance-reviewed memory от модели.

Быстрый старт

Сначала создайте и проведите запись через host-owned v0.10 admission boundary:

from protoprompt.ledger import (
    MemoryAdmissionAction,
    MemoryAdmissionPolicy,
    MemoryKind,
    MemoryOrigin,
    MemoryReviewGate,
    MemoryWriter,
    SqliteMemoryLedger,
)
from protoprompt.ledger.recall import LedgerRecallPlanner, StaleMemoryPlanError
from protoprompt.scope import MemoryScope

ledger = SqliteMemoryLedger("memory-ledger.db")
ledger.setup()
writer = MemoryWriter(
    ledger,
    scope=MemoryScope(tenant="local", user="alice", thread="agent-42"),
)

gate = MemoryReviewGate(
    writer,
    origin=MemoryOrigin.DOCUMENT,
    policy=MemoryAdmissionPolicy(
        policy_id="artifact-facts-v1",
        policy_version="1",
        allowed_origins=(MemoryOrigin.DOCUMENT,),
        minimum_confidence=0.8,
    ),
)
candidate = gate.ingress(
    kind=MemoryKind.FACT,
    source_ref="artifact:checkpoint-manifest",
    confidence=0.9,
).submit("Восстановление checkpoint начинается с durable manifest.")
review = gate.review(candidate.record_id)
assert review.action is MemoryAdmissionAction.ALLOW
gate.confirm(review, event_id="admission:checkpoint-manifest:allow")

planner = LedgerRecallPlanner(writer)
plan = planner.plan(
    task="починить восстановление checkpoint",
    token_budget=600,
    byte_budget=32_768,
)

try:
    memory_data = planner.resolve(plan).render_data()
except StaleMemoryPlanError:
    # Выбранная запись изменилась или больше не допустима. Планируем заново.
    memory_data = planner.resolve(
        planner.plan(
            task="починить восстановление checkpoint",
            token_budget=600,
            byte_budget=32_768,
        )
    ).render_data()

memory_data — канонический JSON envelope:

{
  "records": [
    {
      "content": "Восстановление checkpoint начинается с durable manifest.",
      "kind": "fact"
    }
  ],
  "schema_version": 1,
  "type": "protoprompt.ledger-recall"
}

В нём намеренно нет record ID, scope, content hash, source reference и evidence reference. Модель получает справочные данные, а не инструмент для изменения Ledger.

Граница trusted composition

Сам LedgerRecallPlanner не изменяет WorkingMemory, MemoryService, legacy ContextPlan или system prompt модели и не вызывает провайдера. Это по-прежнему корректный выбор, когда host сам владеет placement данных и окончательным accounting.

Для узкого случая «admitted Ledger JSON → один готовый provider request» есть LedgerContextComposer. Это host-owned, явный opt-in bridge — не автоматическое поведение Ledger, pp-agent или pp-ollama-chat. Он требует у TokenBudgetedContextBuilder и LedgerRecallPlanner один и тот же непустой MemoryScope и тот же экземпляр TokenCounter; planner обязан использовать LedgerRecallPolicy.admission_safe_default() или policy с require_admission_audit=True.

Composer помещает payload памяти только в один user JSON message. Перед ним стоит фиксированный system guard без текста памяти; generated system context не содержит raw Ledger payload. Пара guard+JSON располагается после generated system context (если он есть) и до history/tool graph, поэтому не разрывает tool call/result. Полный lane обязателен: он резервируется до optional RAG/session/history и не обрезается молча. Нехватка места вызывает TokenBudgetExceededError(..., "ledger_data").

from protoprompt import ContextInput, InMemStore, TokenBudgetedContextBuilder
from protoprompt.ledger.recall import (
    LedgerContextComposer,
    LedgerRecallPlanner,
    LedgerRecallPolicy,
    StaleMemoryPlanError,
)
from protoprompt.tokens import RegexTokenCounter

# async host handler
counter = RegexTokenCounter()
builder = TokenBudgetedContextBuilder(
    InMemStore(),
    embedding_client,
    counter=counter,
    max_tokens=4_096,
    scope=writer.scope,
)
planner = LedgerRecallPlanner(
    writer,
    policy=LedgerRecallPolicy.admission_safe_default(),
    counter=counter,
)
composer = LedgerContextComposer(builder, planner)

try:
    request = await composer.plan_messages(
        ContextInput(
            query="починить восстановление checkpoint",
            system_prompt="Следуй контракту хоста.",
            include_session=False,
        ),
        user_message="Что делать дальше?",
        ledger_token_budget=600,
    )
except StaleMemoryPlanError:
    # Lifecycle memory изменился во время async context/RAG work. Планируем заново.
    raise

messages = request.render_messages()  # немедленно отправьте своему provider client
receipt = request.receipt              # точный budget всего сообщения
audit = request.explain()              # content-free metadata

Composer сам не пишет Ledger и не отправляет chat request. Однако переданный TokenBudgetedContextBuilder может асинхронно выполнять RAG/embedding work; после этой работы composer ещё раз вызывает resolve() и fail-closed при stale record/revision/expiry.

При сериализации <, > и & в memory content экранируются, чтобы запись не могла видимо закрыть внешний XML/HTML-подобный wrapper. Это лишь defense in depth, а не замена trusted data boundary: текст памяти может содержать недоверенные инструкции и должен оставаться данными. Содержимое durable record нельзя считать system instructions, а модели нельзя выдавать writer или lifecycle methods как tools.

Политика выбора

LedgerRecallPolicy.safe_default() допускает только fact, decision и preference с confidence не ниже 0.5. episode и procedure по умолчанию исключены. Приложение может opt-in только через явную host policy с evidence и risk contract, подходящими для этих более богатых memory kinds.

Это compatibility-policy standalone planner: она всё ещё может читать legacy unknown и legacy_unknown records. Для composed provider request используйте LedgerRecallPolicy.admission_safe_default(). Она включает require_admission_audit=True, поэтому оба provenance без review-а исключаются; concrete origins дополнительно проходят audited active-reader invariant Ledger.

from protoprompt.ledger import MemoryKind
from protoprompt.ledger.recall import LedgerRecallPlanner, LedgerRecallPolicy

policy = LedgerRecallPolicy(
    policy_id="ops-episodes-v1",
    allowed_kinds=(MemoryKind.FACT, MemoryKind.DECISION, MemoryKind.EPISODE),
    minimum_confidence=0.8,
    active_read_limit=1000,
    candidate_limit=100,
    candidate_scan_byte_budget=1_048_576,
)
planner = LedgerRecallPlanner(writer, policy=policy)

Это immutable local selection policy, а не admission policy. Она не может подтвердить candidate. Admission v0.10 — отдельный host-side MemoryReviewGate: он фиксирует origin и policy до входа текста в Ledger, а затем записывает sealed allow / quarantine / reject decision. Он никогда не позволит model output автоматически превратиться в active memory. Граница RPC-only и recovery описаны в Memory Ledger admission.

При фиксированных task, host-controlled clock, active Ledger snapshot, policy и token counter выбор воспроизводим. Ранжирование использует сначала лексическое совпадение, затем host-set confidence и свежесть для tie-break. Вызывающий код не может передать собственное время и задним числом обойти expiry; фиксированный clock допустим только в trusted test/replay harness.

Planner читает не более active_read_limit локальных active records (по умолчанию 1 000). SQLite materializes payloads для этого bounded active read; фильтр kind/confidence применяется до lexical ranking и candidate-content scan budget, затем рассматривается не более candidate_limit допустимых records (по умолчанию 100). active_record_count, active_read_limit_reached, eligible_record_count и candidate_limit_reached делают эту границу видимой в receipt. Active read, точно дошедший до limit, помечается как потенциально обрезанный вместо ложного заявления о полном глобальном поиске. Budget сырых байтов кандидатов применяется только к eligible candidates: слишком большая запись получает scan_byte_budget и не останавливает рассмотрение более маленьких поздних записей.

Budget receipt и свежесть

LedgerRecallPlan не содержит plaintext памяти или task text. Он хранит лишь private snapshots record/revision и content-free receipt:

plan.explain()
# {
#   "policy_id": "ledger-recall-safe-v1",
#   "used_tokens": 118,
#   "used_bytes": 441,
#   "remaining_tokens": 482,
#   "remaining_bytes": 32327,
#   "selected_count": 2,
#   "decisions": [...],
# }

Receipt также несёт полную content-free конфигурацию policy и её fingerprint, а также counter_id. У встроенного счётчика это regex-token-counter-v1; при своём счётчике приложение должно передать versioned counter_id, чтобы сохранённый receipt явно указывал контракт accounting.

Сам LedgerRecallPlan по-прежнему in-process capability, привязанный к экземпляру planner, а не переносимый checkpoint. Его нельзя сериализовать, передать другому planner или оживить после restart. Для аудита сохраняйте plan.explain(); отдельный sealed-checkpoint API ниже используйте только там, где host-у нужен его узкий restart-safe selection contract.

used_tokens — результат выбранного TokenCounter на полном prospective JSON envelope. used_bytes — strict UTF-8 длина того же envelope. Planner никогда не обрезает запись: она попадает целиком или получает over_token_budget / over_byte_budget; после этого всё равно рассматриваются более маленькие поздние записи.

Авторитетен полный used_tokens envelope. Per-record token cost в receipt — неотрицательная incremental allocation величина, поэтому их сумма может не совпасть точно, если инъецированный детерминированный tokenizer даёт немонотонный count для двух разных JSON strings.

resolve() сначала читает, рендерит и учитывает candidate data вне SQLite writer lock. Затем он берёт короткую exclusive Ledger lifecycle boundary и повторно читает active snapshot, проверяя ID, ревизии, content hash и kind выбранных records непосредственно перед возвратом контекста. Поэтому инъецированный token counter не выполняется внутри database transaction. Свежий host time проверяется и до, и после короткой boundary: запись, у которой срок истёк во время accounting или ожидания lock, также отклоняется. Если concurrent forget()/retract() успел раньше этой финальной проверки, либо запись уже была expired, superseded, hard-erased, не попала в bounded read, сменила ревизию или content hash, возникает StaleMemoryPlanError, а не возвращается устаревший текст. Перед каждым model send нужно заново спланировать и разрешить данные.

У composed request есть ещё один короткий final boundary: после асинхронного context planning и точной проверки отрендеренных messages composer повторно resolve()-ит исходный plan. Изменение выбранной памяти превращается в StaleMemoryPlanError, а не в отправку старого JSON.

LedgerCompositionReceipt связывает policy/fingerprint/counter и факт этой final validation. ContextPlan.data_lanes содержит content-free ContextDataLaneReceipt (количество records, bytes и transport tokens). ContextRequestReceipt.input_tokens остаётся единственным authoritative total всего provider request: lane receipt объясняет его часть, но не заменяет exact count для полного message list.

Sealed recall checkpoints (v0.12)

Sealed checkpoint — явная opt-in restart boundary для одного strict Ledger selection. Schema v6 хранит opaque checkpoint_id, opaque continuation_ref, content-free policy/counter/budget receipt и private selection markers (record ID, revision, content hash и kind). Она не хранит task text, raw memory payload, provider messages, tool output или raw host/agent state. Continuation reference — лишь opaque host-owned handle: Ledger не сериализует то, на что он ссылается.

LedgerRecallPlanner.checkpoint() доступен только при require_admission_audit=True (обычно LedgerRecallPolicy.admission_safe_default()) и стабильном host-owned checkpoint_secret. Секрет размером 32–4096 bytes ставит HMAC-SHA256 seal на durable manifest и никогда не записывается в SQLite или public receipt. После restart новый planner должен получить тот же защищённый secret.

# writer, counter и builder принадлежат host-у и используют один strict scope.
# host_secret — защищённое стабильное значение от 32 bytes; его нет в SQLite.
planner = LedgerRecallPlanner(
    writer,
    policy=LedgerRecallPolicy.admission_safe_default(),
    counter=counter,
    checkpoint_secret=host_secret,
)
plan = planner.plan(task="починить durable recovery", token_budget=600)
checkpoint = planner.checkpoint(
    plan,
    checkpoint_id="checkpoint-42",
    continuation_ref="continuation-42",
)

# После restart создайте writer/builder/planner для того же scope заново.
# Пересозданные builder и planner делят `counter`; planner получает host_secret.
resumed_planner = LedgerRecallPlanner(
    writer,
    policy=LedgerRecallPolicy.admission_safe_default(),
    counter=counter,
    checkpoint_secret=host_secret,
)
resume = resumed_planner.resume_checkpoint(
    checkpoint.checkpoint_id,
    task="починить durable recovery",
)
resumed_composer = LedgerContextComposer(builder, resumed_planner)
request = await resumed_composer.plan_checkpoint_messages(
    resume,
    ContextInput(query="починить durable recovery", include_session=False),
    user_message="Продолжи восстановление.",
)

Свежий task обязателен, потому что task не сохраняется. При вызове plan_checkpoint_messages() он должен совпадать с ContextInput.query: private LedgerRecallResume нельзя скомпоновать с неродственным request. Сохранённые Ledger budgets authoritative — override budget-а при resume нет.

При resume planner проверяет HMAC, требует исходные strict policy ID/fingerprint и counter_id, строит свежий plan со сохранёнными budgets и требует полного совпадения selection tuple и used token/byte receipts. Drift policy/counter, изменившаяся/истёкшая/недопустимая selection или неверная seal fail-closed ещё до появления provider request. Обычная final composer validation остаётся защитой интервала между resume и send.

Любое lifecycle-изменение выбранной записи инвалидирует active checkpoint в той же Ledger transaction и удаляет его private selection markers. То же делает resume, если fresh selection изменился. dry_run_setup() и setup() проверяют структуру v6 sidecar и её relational invariants, но не могут подтвердить HMAC: это может сделать только host с checkpoint_secret при resume.

Здесь намеренно нет lease, exactly-once delivery, workflow engine или автоматического подключения к pp-agent, pp-ollama-chat, legacy memory либо provider sessions. Каждый checkpoint host явно создаёт, resume-ит и компонует.

Scope и граница удаления

Planner получает MemoryWriter, а не параметр scope, поэтому не может расширить память Alice до scope Bob. Его active-memory reader path проверяет lifecycle Ledger и точный host scope.

Планирование не делает записей. LedgerRecallPlan не копирует plaintext, но короткоживущий LedgerRecallContext, возвращённый resolve(), неизбежно содержит отрендеренные данные в памяти процесса. Приложение, которое сохраняет или отправляет эту строку, отвечает за собственную process/provider retention boundary. forget() и erase() по-прежнему удаляют live Ledger payload в пределах, описанных в Memory Ledger guide, но не могут ретроспективно стереть context string, уже возвращённую или отправленную куда-либо приложением.

То же относится к LedgerComposedRequest: он transient и должен быть отправлен сразу. Его final validation действует до возврата из plan_messages(); поздний forget() не может отозвать JSON, который host уже передал provider-у.

Frozen dual-backend evidence protocol (v1.0)

В репозитории есть узкий fixture протокола доказательств v1.0 для strict Ledger read-path. Это не заявление о package release 1.0.0 и не benchmark модели, embedding-service, provider-а, latency, throughput или общего качества retrieval.

Fixture запускает 18 delayed-recall и lifecycle cases на fresh SQLite и PostgreSQL Ledger. После нормализации только ID backend-а оба должны выдать один content-free semantic report. Fixed checks покрывают tenant/user/thread scope isolation, strict admitted records, whole-record token/UTF-8-byte budgets, plan/resolve receipts, lifecycle exclusion, source-revocation scrubbing и content-free explain().

Полная проверка запускается только при наличии обоих локальных durable backend-ов:

pip install -e ".[postgres,dev]"
$env:PROTOPROMPT_POSTGRES_DSN = "postgresql://protoprompt:protoprompt@localhost:5432/protoprompt_test"
python scripts/run_memory_benchmark.py --suite v1.0 --ledger-backend all --verify

Число strict Ledger 9/9 против 20-record tail 0/9 — это только доступность target в этом именованном synthetic lexical fixture: query содержит слова target, а fillers — нет. Его нельзя использовать как claim о качестве ответа модели, сравнении внешних фреймворков, production quality или performance. Точная методика — в benchmark protocol и связанных suite.json, expected.json, manifest.json.