Consolidation · module 3 of 8 · The Write Path in Earnest · lesson 22 of 84 · ~45 min
$
uv run python curriculum/intermediate/summarization-and-compaction/lab/lab.py
Summarization and Compaction¶
In one line. Compression comes entirely from what you decline to carry forward — and a summary without
derived_fromis an orphan claim that cannot be rebuilt, traced, or deleted.
Where this sits¶
Stage: evolve · Level: intermediate · ~45 min
You need first: Deduplication
Concepts assumed: Deduplication · Token Budget · Provenance
This unlocks: Semantic Drift
The problem¶
Priya's store is 37 memories after deduplication. In two years it will be thousands, and the token budget will still be a few hundred. Something has to compress.
The obvious move is a summary per session. Build one, measure it, and the first result is not what you expect:
| session | sources kept | dropped | compression |
|---|---|---|---|
| s1 | 4 | 30 | 0.95 |
| s2 | 2 | 0 | 1.22 |
| s4 | 2 | 0 | 1.40 |
| s5 | 3 | 42 | 0.84 |
| s8 | 3 | 97 | 0.64 |
Six of the ten summaries got bigger. The summary of session 4 is 1.4× the size of session 4.
Nothing is broken. An extractive summary that keeps every claim is not a compression — it is a re-joining, and it pays for the joining. Read the dropped column: every session that expanded dropped zero characters. Sessions 2 and 4 contain nothing but semantic claims, so there was nothing to discard, and the summary added punctuation to a list it kept whole.
Compression is entirely a function of what you throw away. That reframes the design question from how do I summarise to what am I willing to lose, which is a question with a defensible answer.
Why this isn't RAG¶
Document summarisation produces a new artifact beside a corpus that stays put. The original is still there, unchanged, and the summary is a convenience — delete it and rebuild it any time.
A memory summary is meant to replace what it summarises, or it saves nothing. That makes it load-bearing: something must record what it was built from, so it can be rebuilt when a source changes and deleted when a source is deleted. Otherwise you get the failure deletion-that-actually-deletes is named for — you removed the episode, and the summary still knows.
Mechanism¶
What gets dropped: episodes. Sessions are summarised from their semantic and procedural claims only. That is a real, arguable choice — episodes are the record of what happened, and a summary of them loses the timestamps that made them worth keeping. Standing beliefs compress; dated events should be archived or forgotten, not blurred.
Across the store that yields 1432 → 1175 characters, about 0.82. Modest, and honest: the compression is exactly the size of what was dropped — and it comes from four sessions, while six worked against it.
What makes it safe: derived_from.
summary = Memory(
content=f"Summary of {session}: {claims}",
derived_from=tuple(sorted(m.id for m in members)), # every source, by id
)
This is a field the Beginner record did not have, added here because summarisation is the first thing that needed it. Worth pausing on the cost of that timing: adding a defaulted field now is a one-line change, and adding it after a million summaries exist means backfilling provenance that was never recorded — which is impossible, not merely expensive. the-memory-record argued the schema is the one irreversible decision; this is what "irreversible" costs when you get it slightly wrong, and it is cheap here only because the store is small.
orphaned_summaries is the check that makes the field earn its place: after any deletion, a summary whose sources are no longer all live is stale at best and a privacy violation at worst.
Extractive, not generative. Every sentence in the summary exists verbatim in the store, so every claim is attributable and none can be invented. Generative summaries read better and lose that: a fluent sentence covering three memories cannot be traced to any of them, and a hallucinated one cannot be distinguished from a real one. Fluency is worth less than provenance here.
flowchart LR
S[("a session's memories")] --> T{"type"}
T -->|episodic| DR["dropped<br/><i>dated events are archived<br/>or forgotten, not blurred</i>"]
T -->|semantic · procedural| C["<b>extractive summary</b><br/><i>every sentence verbatim,<br/>so every claim is attributable</i>"]
C --> P["<b>derived_from</b><br/><i>every source, by id</i>"]
P --> O{"are all sources<br/>still live?"}
O -->|yes| OK["safe to serve"]
O -->|no| ST["orphaned — stale at best,<br/>a privacy violation at worst"]
C -.->|"never"| X["<b>generative summary</b><br/><i>fluent, and no sentence<br/>traceable to a source</i>"]:::bad
style C fill:#aed6f1,stroke:#2874a6
style P fill:#aed6f1,stroke:#2874a6,stroke-width:2px
style O fill:#f9e79f,stroke:#b7950b
classDef bad fill:#f5b7b1,stroke:#c0392b,stroke-dasharray: 4
Design decisions¶
Summarise per session, or rolling across sessions? Per session, because the session is a natural boundary the corpus already carries and it keeps derived_from small and stable. Rolling windows re-derive far more on every change.
Keep the sources after summarising? Yes, at this scale — a summary that replaces its sources is only safe once you are certain nothing else needs them, and archival is a Level 3 decision under real cost pressure. derived_from is what makes deferring that decision possible.
Drop episodes, or drop by salience? Episodes, deliberately, because it is a rule you can state and defend. Salience-based dropping is better and needs a salience signal that does not exist until I5 — and a lossy rule you cannot explain is worse than a blunt one you can.
Lab¶
You'll implement: summarise_session with derived_from, and orphaned_summaries.
Run:
uv run python curriculum/intermediate/summarization-and-compaction/lab/lab.py
Expected output: 10 summaries; the per-session table above, with six rows flagged EXPANDED; the whole-store figure of 1432 → 1175; then a source is deleted and orphaned_summaries detects exactly one stale summary.
One row is summary:calendar-agent rather than a session — the shared-scope agent writes group under their own source prefix. Worth noticing rather than special-casing: "session" was never the real unit, source_id was.
Stretch: compute compression against the summary's own sources rather than the whole session. Every number goes above 1.0 and the technique looks useless — the correct answer to the wrong question. Choosing the denominator is the measurement.
What this adds to the capstone¶
memlab.evolve.summarize — summarise_session, summarise_all, orphaned_summaries, Summary. And Memory.derived_from, which semantic drift needs immediately and cascade deletion needs in Advanced.
Not wired into the pipeline, deliberately. At 37 memories there is no budget pressure to relieve, and a compaction step that runs for no reason is a lossy transform applied for free. It gets switched on in decay and tiers, once forgetting gives the store a bound worth respecting.
Failure modes¶
| Symptom | Cause | How to detect | Mitigation |
|---|---|---|---|
| Summaries make the store bigger | Nothing was dropped | Measure compression per session; look for >1.0 | Decide explicitly what to lose |
| Deleted data survives in a summary | No derived_from |
Delete a source, grep summaries for its content | Record sources; run orphaned_summaries |
| A summary claims something no source said | Generative summarisation | Check each claim appears verbatim in a source | Extractive, or verify claims |
| Summaries go stale silently | Sources changed, summary not rebuilt | Compare summary age to newest source | Re-derive on source change |
| Dates vanish from history | Episodes summarised away | Ask when something happened | Summarise beliefs; archive episodes |
Check yourself¶
Session 4's summary is 1.4× the size of session 4. Is the summariser broken?
No — session 4 contains two semantic claims and no episodes, so there was nothing to drop and the summary re-joined them with punctuation. It is a correct summary of a session that had no redundancy. The lesson is that compression is not a property of the summariser, it is a property of what the input allows you to discard.
Why record derived_from when the summary text already contains the claims verbatim?
Because text matching is not identity. Two memories can share wording, a source can be edited, and a claim can appear in several sessions. Deletion and rebuild both need to know which records this was built from, and only ids answer that.
derived_from was added in Level 2, after the record was supposedly settled. Doesn't that undercut the earlier lesson?
It illustrates it. Adding a defaulted field is trivial now, with 37 memories and no summaries in existence. The same change after a million summaries means backfilling links that were never recorded — and that data does not exist to be recovered. The schema is not unchangeable; it is unchangeable cheaply, and the window closes quietly.
Connections¶
Stage: evolve · Level: intermediate · ~45 min
You need first: Deduplication
Concepts assumed: Deduplication · Token Budget · Provenance
This unlocks: Semantic Drift