feat(layers): select L1 wake-up drawers by salience - #2169
Conversation
|
Eight synthetic drawers, one wing, same palace, Those five are a deadlock root cause, the migration that finally worked, a retry fix, a build that died on |
24f055f to
c3a7a4a
Compare
|
You are right, and the repro is worse than it looks. I reproduced your eight drawers: all five outcome drawers are dropped, and because the three prose drawers survive, the everything-junk fallback never fires, so the loss is silent. The part I had missed is that the five dropped drawers each scored 2 and the three survivors scored 1. The filter was not just losing content, it was inverting the ranking and keeping the chatter. Fixed by separating two things I had wrongly merged. Structural harness wrappers ( I want to flag one thing I tried and threw away, because it was the same bug wearing a different hat. My first pass also dropped any body that repeated a wrapper, on the theory that a repeat means concatenated injections. That rule was unanchored, so it re-created your complaint: an engineer explaining why a reminder fired twice writes the tag twice and loses the drawer. It is gone. Genuine concatenated soup is already caught by table density and the prose floor, neither of which needs a marker named, and there is now a regression test for prose that names the same wrapper twice mid-sentence.
To stop that from just readmitting log soup, exclusion is now based on shape rather than on naming markers: a prose ratio over the whole body, characters that are letters or spaces. That drops JSON, log lines, env dumps, URL lists, Your five drawers all survive and all outrank the background prose. Both your repro and a soup corpus that names no markers are now tests. Three other things came out of chasing this, all folded into the one commit since this never merged:
Still open and worth a follow-up: the keyword list has no failure vocabulary ("died", "crashed", "timed out"), so your build drawer only ranks as an outcome because the sentence ends "and it passed". Rebased on current develop. Full suite 3878 passed / 31 skipped against 3850 on develop in the same environment; ruff 0.16.1 clean. |
c3a7a4a to
f9a7d0d
Compare
|
The rework is pushed. Exclusion is now structural and anchored only: a drawer is dropped for a harness wrapper opening it ( Your eight-drawer scenario is now a checked-in regression test ( Rebased onto |
Issue MemPalace#1629 asked why wake-up did not read like the essential story it promises. Ordering was half the answer and is fixed. The other half is what gets in: L1 has room for about 15 lines, and it filled them with whatever sorted highest, including harness injection blocks, raw tool output, markdown tables, bare timestamp lines, three near-identical chunks of one transcript, and snippets that opened mid-word. On a 149k-drawer palace the result read as fragment soup. Selection now happens between sorting and rendering. _l1_salience(text) scores a candidate. Negative means junk and drops it. Exclusion is deliberately narrow and structural, never lexical: - a harness wrapper *opening* the drawer (<system-reminder>, <command-message>, <local-command-caveat>, <task-notification>, [SYSTEM NOTIFICATION), matched with startswith after normalization - a timestamp-only or rule-only drawer - text below a 20-character floor - pipe/dash density above 0.18, which is a table, a diff or an ASCII rule - a prose ratio below 0.78, the fraction of characters that are letters or spaces The prose ratio is the load-bearing rule and the general form of a marker list: JSON, ls output, env dumps, URL lists, log lines and bare code fences all fail it without any marker being named, while an engineering sentence quoting a path, a version pin or a fence passes. Measured over the 21-item corpus in test_l1_salience_drops_soup_without_markers, soup lands in 0.40 to 0.82 and prose in 0.84 to 0.99. The floor sits at 0.78, deliberately below the gap: a false drop loses a real memory forever, a false keep costs one wake-up line. The residue that clears it, word-heavy tracebacks and diffs, is demoted rather than excluded. Nothing is excluded for merely mentioning tooling. tool_use, tool_result, fences, "Exit code:" and "cwd was reset" cost a ranking point and nothing more, because engineering writing quotes those constantly. Positive scoring is outcome wording (2 points), a clean sentence or heading start (1), and freedom from tool noise (1). Outcome is worth strictly more than both presentation signals, otherwise a drawer that is both an outcome and a little noisy ties with background chatter that merely reads cleanly: outcome + clean 4 chatter + clean 2 outcome + noisy 3 chatter + noisy 1 Anchoring rather than substring matching is the whole correctness argument here. An earlier revision also dropped any body that *repeated* a wrapper, reasoning that a repeat means concatenated injections. That rule was unanchored and re-created the same defect: an engineer writing about why a reminder fired twice names the tag twice. It is gone. Genuine concatenated soup is already caught by density and the prose floor, neither of which needs a marker named. _l1_select applies the caps. At most L1_MAX_PER_SOURCE (2) lines from one source file, so a single long transcript cannot own the story. Drawers with no source_file are uncapped, because the cap exists to stop one known file dominating and lumping unattributed drawers under one empty key would do the opposite. Near-duplicates collapse on whole-body word overlap at 0.8, not on a leading slice: mined session summaries routinely share a templated opening, and matching the first 80 characters collapsed three summaries reporting a migration, a revert and an RTL fix into one and lost two outcomes silently. Whole-body overlap scores that case at 0.27 and keeps all three, while genuine restatements stay above 0.84. The comparison list never exceeds max_drawers, so selection stays linear: measured on this machine at 8.7 ms for 500 candidates, 16.9 ms for 1,000 and 34.2 ms for 2,000, which is the MAX_SCAN ceiling, against the repo's 100 ms startup-injection budget. Rendering groups rooms in the order the ranked list arrives, not alphabetically. Grouping by room and then sorting room names before the MAX_CHARS truncation undid the ranking at the last step: a salience-4 outcome in a room named zzz_outcomes was cut while salience-2 chatter in aaa_trivia rendered. _l1_snippet composes the line: start after the next sentence boundary when a chunk opens mid-sentence, cut on a word boundary. The result is always a contiguous substring of the drawer, never a paraphrase, so the verbatim promise holds. Filtered drawers are untouched in the palace and still returned by L2 and L3. When every candidate scores as junk, L1 renders exactly what it rendered before the filter. An empty wake-up is the worse failure. No LLM call, no embedding, no I/O: a few passes over text already in memory, because L1 runs inside the wake-up hook's latency budget. Two details for non-English palaces. The clean-start point tests "not lowercase" rather than "is uppercase", so Arabic, Hebrew and CJK are not permanently denied a point that every English drawer collects. And L1_OUTCOME_KEYWORDS is English, which its docstring says along with the real two-step for extending it: the regex is compiled once at import, so appending to the tuple alone does nothing, and _l1_compile_outcome_re is exposed for that reason. The junk, cap, duplicate and boundary rules are language-neutral. Known gap, left for a follow-up to keep this in scope: the outcome keyword list has no failure vocabulary. "died", "crashed", "OOM", "timed out" and "regressed" are absent, so a drawer reporting a failure ranks as chatter unless the sentence happens to also say something passed. Orthogonal to PR MemPalace#1950 (per-drawer salience from the dynamics model plus retrieval potentiation). That work scores drawers at retrieval time in dynamics.py/searcher.py/service.py/mcp_server.py; this is snippet-level composition inside layers.py and touches none of those files. If MemPalace#1950 lands, its per-drawer signal can feed the importance key L1 already sorts on, and this filter keeps doing the part it does. Tests: salience edges (anchored wrapper, prose naming a wrapper twice, date-only, table soup, short text, outcome boost, fragment vs whole thought, the ISO-designator false positive, non-Latin prose scoring like Latin), a marker-free soup corpus, noise demotion ordering, snippet boundaries (sentence start, no-boundary passthrough, word-boundary cut, newline collapse), selection (per-source cap, unattributed drawers uncapped, templated-lead summaries kept, near-duplicate suppression, tier order, all-junk empty), render order under the character cap, and end to end through Layer1.generate including the fallback and a verbatim-substring assertion.
f9a7d0d to
6d44f3e
Compare
What does this PR do?
#1629 asked why wake-up did not read like the "essential story" it promises. Ordering was half the answer and is fixed. The other half is what gets in.
L1 has room for about 15 lines and fills them with whatever sorted highest. On a real mined palace that means harness injection blocks, raw tool output, markdown tables, bare timestamp lines, three near-identical chunks of the same transcript, and snippets that open in the middle of a word. The layer that is supposed to be the first thing an agent reads was reading as fragment soup, and no amount of re-sorting fixes it because the junk sorts exactly like the story.
This adds a deterministic selection pass between the existing sort and the render. No LLM call, no embedding, no I/O: a few passes over text already in memory, because L1 runs inside the wake-up hook's latency budget.
What gets excluded, and why it is not a marker list
Exclusion is structural, never lexical.
_l1_salience(text)returns negative, and the drawer is dropped, only for:<system-reminder>,<command-message>,<local-command-caveat>,<task-notification>,[SYSTEM NOTIFICATION), matched withstartswithafter normalizationThe prose ratio is the load-bearing rule and the reason this is not a longer ban list. It is the general form of what a marker list reaches for: JSON,
lsoutput, env dumps, URL lists, log lines and bare code fences all fail it without any marker being named, while an engineering sentence quoting a path, a version pin or a fence passes.Calibrated over the 21-item corpus in
test_l1_salience_drops_soup_without_markers:The populations separate by 0.016. The floor sits at 0.78, deliberately below the gap: a false drop loses a real memory forever, a false keep costs one wake-up line. The two things that clear the floor and should not have (a word-heavy Python traceback at 0.800, a git diff at 0.822) are demoted, not excluded.
Nothing is excluded for merely mentioning tooling.
tool_use,tool_result, fences,Exit code:andcwd was resetcost a ranking point and nothing more, because engineering writing quotes those constantly.Ranking
Outcome wording is worth 2 points, a clean sentence or heading start 1, and freedom from tool noise 1. Outcome is worth strictly more than both presentation signals, otherwise a drawer that is both an outcome and a little noisy ties with background chatter that merely reads cleanly:
Outcome always beats chatter regardless of noise.
Caps and composition
_l1_select(...): at mostL1_MAX_PER_SOURCE(2) lines from any one source file, so a single long transcript cannot own the story. Drawers with nosource_fileare uncapped, because the cap exists to stop one known file dominating and lumping unattributed drawers under one empty key would do the opposite. Worth knowing: a palace whose metadata lackssource_fileentirely gets no cap at all.MAX_CHARStruncation undid the ranking at the last step: a salience-4 outcome in a room namedzzz_outcomeswas cut while salience-2 chatter inaaa_triviarendered._l1_snippet(...)composes the line: start after the next sentence boundary when a chunk opens mid-sentence, cut on a word boundary. The result is always a contiguous substring of the drawer, never a paraphrase, so the verbatim promise holds. Filtered drawers are untouched in the palace and still returned by L2 and L3.Non-English palaces
Two details. The clean-start point tests "not lowercase" rather than "is uppercase", so Arabic, Hebrew and CJK are not permanently denied a point that every English drawer collects. And
L1_OUTCOME_KEYWORDSis English, which its docstring says along with the real two-step for extending it: the regex is compiled once at import, so appending to the tuple alone does nothing, and_l1_compile_outcome_re()is exposed for that reason. The junk, cap, duplicate and boundary rules are language-neutral. The prose ratio is character-based rather than token-based for the same reason: Chinese has no spaces, so a token-based ratio scored space-free CJK at 0.00.Known gap, deliberately left for a follow-up
The outcome keyword list has no failure vocabulary. "died", "crashed", "OOM", "timed out" and "regressed" are absent, so a drawer reporting a failure ranks as chatter unless the sentence happens to also say something passed. Kept out of this PR to keep it in scope; worth its own change.
Orthogonal to #1950
That PR adds per-drawer salience from the dynamics model plus retrieval potentiation, in
dynamics.py,searcher.py,service.pyandmcp_server.py. This is snippet-level composition insidelayers.pyand touches none of those files. If #1950 lands, its per-drawer signal can populate theimportancekey that L1 already sorts on, and this filter keeps doing the part it does. The two do not overlap in files or in intent.Measured on the 149k-drawer palace this was written for: wake-up reads as a 7-source outcome story in 0.72s, against the oldest-backfill fragment soup it used to print.
How to test
Baseline
origin/develop@906b918(develop after the 3.7.1 sync) in the same environment: 4295 passed, 31 skipped, so +28 tests and no existing test changed or deleted. ruff 0.16.1 (the repo pin), Python 3.12.13.@mvalentsev's eight-drawer repro is a test. Against the earlier revision of this filter, which matched markers as substrings anywhere in the body:
All five outcome drawers dropped, and because the three prose drawers survive the everything-junk fallback never fires, so the loss was silent. Worse than reported: the three survivors scored 1 and the five dropped would each have scored 2. The filter was not merely losing content, it was inverting its own ranking and keeping the chatter. All five now survive and all five outrank the background prose.
The render-order defect, demonstrated:
The near-duplicate defect, across three keying strategies:
Cost of the whole-body comparison. The comparison list never exceeds
max_drawers, so selection stays linear in candidates. Measured on this machine, median of 15 runs:against the repo's stated 100 ms startup-injection budget.
Test coverage: salience edges (anchored wrapper, prose naming a wrapper twice, date-only and rule-only text, table soup, sub-floor text, the outcome boost, a mid-sentence fragment ranking below the same sentence whole, the ISO-designator false-positive guard, non-Latin prose scoring like Latin); a marker-free soup corpus (JSON,
lsoutput, log lines, env dump, URL list, pytest tail, traceback, git diff), which is the adversarial case for the new design since none of it would have been caught by any ban list; noise demotion ordering and fenced code ranking below a clean outcome; snippet boundaries (sentence start, no-boundary passthrough, word-boundary cut, newline collapse); selection (per-source cap, unattributed drawers uncapped, templated-lead summaries kept, near-duplicate suppression, tier order, all-junk empty); render order under the character cap; and end to end throughLayer1.generateincluding the fallback and a verbatim-substring assertion.Landing order with #2168. The two branches were merged in a scratch worktree.
mempalace/layers.pyauto-merges with no conflict. The only textual conflicts are additive and both resolve by union: the shared import block at the top oftests/test_layers.py, and the## [Unreleased]anchor inCHANGELOG.md. The composed tree was verified green at the base where the compose was run (3903 passed, 31 skipped, exactly3850 + 25 + 28, ruff clean), so nothing is lost or duplicated by combining them; both branches now sit rebased on the same post-3.7.1 develop (906b918) and the conflict shape is unchanged. Whichever lands second needs that two-hunk rebase and nothing more.One thing this PR does not promise. The per-source cap and the near-duplicate collapse can leave L1 with fewer than
MAX_DRAWERSlines on a palace whose candidate window is dominated by one file or by templated summaries. That is deliberate: underfilling the wake-up with distinct content beats filling it with two lines of the same thing.Checklist
python -m pytest tests/ -v) — 4323 passed, 31 skipped, against 4295 on develop in the same envruff check .) — clean on ruff 0.16.1, along withruff format --check .Docs: a "What Layer 1 leaves out" section in
website/concepts/memory-stack.md, and a CHANGELOG entry under Unreleased. No new dependencies.