Skip to content

fix: layer memory_category onto reflection-mapped rows (write-time, read-time, backfill) - #952

Merged
rwmjhb merged 11 commits into
CortexReach:masterfrom
gorkem2020:fix/memory-category-layers
Jul 30, 2026
Merged

fix: layer memory_category onto reflection-mapped rows (write-time, read-time, backfill)#952
rwmjhb merged 11 commits into
CortexReach:masterfrom
gorkem2020:fix/memory-category-layers

Conversation

@gorkem2020

@gorkem2020 gorkem2020 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Reflection-mapped rows (the "User model deltas", "Agent model deltas", "Lessons & pitfalls", and "Decisions (durable)" sections written by the reflection writer) carry a mappedCategory/mappedKind in their metadata, but never a memory_category in the 6-category sense that the rest of the store uses. Readers fall back to deriving one from the row's legacy store category via reverseMapLegacyCategory, and that fallback sends mapped decision rows straight into events, an append-only category. Since durable operator/system decisions are not one-off occurrences, they end up permanently shielded from consolidation and dedup for the wrong reason: not because they are append-only in nature, but because of a mapping gap.

This PR closes that gap in three layers, following the write-time stamping pattern from #950 and building on the single-sourced category map from #935.

Layer 1: write-time stamping

buildReflectionMappedMetadata now stamps memory_category directly from the row's mappedKind, since the kind is known structurally at write time (each one comes from a fixed reflection section) rather than needing to be guessed from text:

  • user-model, agent-model -> preferences
  • lesson, decision -> cases

This is forward-only and has zero impact on existing stores; only new rows written after this lands get the stamp.

Layer 2: read-time fallback correction

For rows that never migrate (written before this lands, or from any other source relying on the fallback), reverseMapLegacyCategory's decision case now resolves through the same branch as fact instead of defaulting to events. This is a behavior change for any pre-existing row that hits this fallback with a legacy decision category: it will now read as cases (or profile for personal-identity-shaped text, same heuristic fact already used) instead of events.

Layer 3: opt-in backfill

memory-pro upgrade gains a --categories-only flag that runs a new, narrower one-shot pass (MemoryUpgrader.normalizeMappedRowCategories) to re-stamp memory_category on existing reflection-mapped rows using the same mapping as layer 1. It is separate from the general legacy-upgrade sweep (which deliberately excludes reflection rows), touches only that one field, and only when the value is missing or wrong. Idempotent: a row that's already correct is left alone, so a second run makes zero further changes. Supports --dry-run and --scope, matching the existing command's conventions.

Testing

  • test/reverse-map-legacy-category.test.mjs
  • test/reflection-mapped-category-stamping.test.mjs
  • test/memory-upgrader-category-normalization.test.mjs

All three are registered in package.json's test chain and scripts/ci-test-manifest.mjs's storage-and-schema group. Full local suite, tsc --noEmit, and npm run build all pass clean.

Update (2026-07-18)

Rebased onto current master. This revision also single-sources the heading-to-taxonomy map: the metadata stamp and the stored row category both read REFLECTION_MAPPED_MEMORY_CATEGORY, agent self-observations now map to patterns instead of polluting user preferences, and mapped rows mint their smart taxonomy category as the row category rather than the legacy preference/fact/decision names. The upgrader's mapped-row normalization re-stamps existing rows through the same map. Verified live on our fleet.

@gorkem2020
gorkem2020 force-pushed the fix/memory-category-layers branch 2 times, most recently from c6d13e0 to 1790de9 Compare July 18, 2026 15:52
@gorkem2020
gorkem2020 force-pushed the fix/memory-category-layers branch from 257fce6 to aa79cc6 Compare July 29, 2026 02:42
@gorkem2020
gorkem2020 marked this pull request as ready for review July 29, 2026 02:42

@rwmjhb rwmjhb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head aa79cc6. The targeted tests, full suite, and repository CI pass, but the write path violates the primary category-column contract.

index.ts stores smart taxonomy values (preferences, patterns, cases) directly in MemoryEntry.category via a cast, while that column is defined in the legacy storage vocabulary (preference, fact, decision, etc.) and the repository already provides getStorageCategoryForMemoryCategory for this conversion. Direct consumers therefore see an incompatible value. A focused compaction check showed both cases and preferences sources being reconstructed with memory_category: patterns; the new preferences shape also derives a different default layer from an equivalent legacy-backed row. With compaction enabled, correctly stamped source rows can be deleted and replaced by a misclassified merged row.

Please keep entry.category in the legacy storage vocabulary and store the six-category value only in metadata.memory_category. Add production-path tests covering compaction and default-layer parity between newly written and backfilled rows.

Important follow-ups: paginate --categories-only beyond the fixed 10,000-row first page, keep admission and persistence on the same centralized mapping, restrict legacy-decision fallback to identifiable mapped rows, and merge category metadata against the latest locked row to avoid lost updates.

@gorkem2020

Copy link
Copy Markdown
Contributor Author

Fixed in 69fab12. The write site now derives the stored column through the central smart-to-storage mapping (getStorageCategoryForMemoryCategory via a new getReflectionMappedStorageCategory helper), and the six-category value lives only in metadata.memory_category.

Read side, two hardenings for rows already written with six-category column values: reverseMapLegacyCategory reads a six-category value back as itself instead of defaulting to patterns, and the decision-to-cases redirect is now gated on the row's own mapped identity (metadata type), so a bare legacy decision row keeps the canonical decision-to-events mapping your sibling translators use. Layer derivation prefers a valid stamped memory_category, which restores default-layer parity between newly written and backfilled rows.

Follow-ups from your review, dispositions:

  • Pagination: fixed. The categories-only backfill pages the whole store (pageSize option, default 1000) until a short page, no fixed first-page cap.
  • Centralized mapping: fixed. Admission scoring now reads the same kind-to-category table the persisted stamp comes from; a consequence is that mapped decision rows are judged under cases, matching their stored register (they scored under events before, which disagreed with their own stamp).
  • Legacy-decision fallback restriction: fixed as above.
  • Merge against the latest row: the backfill now re-reads each row per chunk immediately before building its patch, so concurrent metadata writes between scan and write survive; the store-level replace of the metadata string is still last-writer-wins inside that millisecond window. A true atomic field merge (the mergeInsert pattern applyManualRecallMetadataBatch uses) is the durable shape and stays tracked as a follow-up.

Validation: new red-proofed cells for the compaction reconstruction (cases and preferences sources no longer collapse to patterns), default-layer parity, the storage-column vocabulary pins, backfill pagination, column repair, and fresh-read patch building (10 cells red pre-fix, all green post). Full suite 454/454, tsc clean, cli-smoke group green, dist rebuilt in the same commit.

@rwmjhb

rwmjhb commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks for addressing the primary storage-category blocker in 69fab12. The current head now has merge conflicts with the latest master, so I cannot reliably verify the fix against the code that will be merged. Please rebase onto the latest master, resolve the conflicts, rerun the focused category/backfill tests and full CI suite, and push the resolved head for re-review.

gorkem2020 and others added 11 commits July 30, 2026 06:14
reverseMapLegacyCategory routed the legacy "decision" store category
through its own branch, defaulting to "events" (append-only). This
shields never-migrated reflection-mapped decision rows, and anything
else relying on the fallback, from consolidation/dedup even though
they are durable operational facts, not one-off occurrences. Merge
"decision" into the same branch as "fact" so it resolves to "cases"
(or "profile" for personal-identity text) exactly like fact does.
Reflection-mapped rows carried mappedCategory/mappedKind but no
memory_category, so readers derived it via reverseMapLegacyCategory
from the row-level legacy category alone. mappedKind is known
structurally at write time (each kind comes from a fixed reflection
section), so stamp memory_category directly from a static
mappedKind lookup instead of relying on the read-time fallback:
user-model/agent-model -> preferences, lesson/decision -> cases.
Zero impact on existing stores; only new rows going forward get the
stamp (see the follow-up opt-in `memory-pro upgrade` pass for
backfilling existing rows).
Reflection-mapped rows written before write-time memory_category
stamping (and any store an operator hasn't re-run since) still lack
the stamp entirely. isLegacyMemory()/upgrade() deliberately exclude
reflection rows, so the general legacy-upgrade sweep never reaches
them either.

Add MemoryUpgrader.normalizeMappedRowCategories(): a narrower,
one-shot pass that scans for memory-reflection-mapped rows and
re-stamps memory_category from the same mappedKind lookup new rows
get, touching only that field and only when the stamped value is
missing or wrong. Idempotent (a row already correct is left alone,
so a second run is a no-op) and dry-run capable. Wired up as
`memory-pro upgrade --categories-only [--dry-run] [--scope]`,
reusing the existing command's flag conventions.
Wire the three new tests (reverse-map-legacy-category,
reflection-mapped-category-stamping, memory-upgrader-category-
normalization) into package.json's local test chain and
scripts/ci-test-manifest.mjs's storage-and-schema group.
…el rows become patterns

The heading->category map now has one source of truth
(REFLECTION_MAPPED_MEMORY_CATEGORY, keyed by structural kind): the
metadata stamp and the stored row category both read it. Agent
self-observations map to patterns instead of polluting user preferences,
and mapped rows mint their smart taxonomy category as the row category
instead of the legacy preference/fact/decision names. The upgrader's
mapped-row normalization re-stamps existing rows through the same map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mapped or item row is one distilled line, so the line is its own
abstract and content and the section heading forms the overview.
Level-less rows fell back to three identical Abstract/Overview/Content
lines in every shared pipeline prompt.
…lary

The mapped-row persist site cast the six-category taxonomy value straight
into the legacy-typed category column, so compaction's plurality vote and
the read-time reverse mapping mis-defaulted those rows to patterns, and
newly written rows derived a different default layer than equivalent
legacy-backed rows.

- persist mapped rows through the central smart-to-storage mapping; the
  six-category value lives only in metadata.memory_category
- reverse mapping reads six-category column values back as themselves
  (tolerance for rows written by earlier builds) and applies the
  decision-to-cases redirect only to identifiable mapped rows; bare legacy
  decision rows keep the canonical decision-to-events mapping
- layer derivation prefers a valid stamped memory_category, restoring
  default-layer parity between newly written and backfilled rows
- admission scoring reads the same kind-to-category table the persisted
  stamp uses, so judge register and stored register always agree
- the categories-only backfill pages the whole store (no fixed first-page
  cap), repairs six-category column values back to storage vocabulary, and
  builds each patch from a fresh per-chunk read so concurrent metadata
  writes survive
@gorkem2020

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (post #927/#943/#974 merges). Semantic reconciliation was confined to the reflection persist site: the branch now layers its mappedKind taxonomy and storage-category mapping on top of the merged #943 mirror callback (both behaviors preserved). Everything else was mechanical test-registration unions. Gates green on the new head: typecheck, full suite, manifest verifier, fresh dist.

@gorkem2020
gorkem2020 force-pushed the fix/memory-category-layers branch from 69fab12 to 05457c0 Compare July 30, 2026 03:18

@rwmjhb rwmjhb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed rebased head 05457c0. The prior storage-contract blocker is fixed: mapped rows keep the legacy storage vocabulary in entry.category, while the six-category value remains in metadata.memory_category. The rebased persistence path preserves the merged #943 mirror callback, and the updated pagination, admission mapping, legacy-decision fallback, compaction/parity regressions, full suite, packaging checks, and repository CI all pass. I found no remaining HIGH/CRITICAL issue. Approving.

Follow-ups worth addressing: derive unstamped historical agent-model rows from mappedKind; make the category backfill an atomic field-level metadata merge; preserve valid member category stamps during compaction; honor or reject --limit explicitly in --categories-only; document that the command may also repair the storage column; and remove the whole-file index.ts line-ending churn/dead import.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants