Skip to content

feat(sleep): 情感归因相位 + affective_context 关系类型(#619) - #628

Open
modusensus wants to merge 9 commits into
mainfrom
feat/619-affective-context
Open

modusensus wants to merge 9 commits into
mainfrom
feat/619-affective-context

Conversation

@modusensus

Copy link
Copy Markdown
Member

背景

#619:误归因探针(PREREGISTRATION.md)的 structured 臂依赖一个带 attribution 的情感关系类型 affective_context,用来区分「情境状态(situational)」与「稳定特质(trait)」。此前 src/ 零命中:抽取器白名单只有四种关系、未知类型静默降级成 related_to(写入成功、图上没有情感轴)。

改动(9 个提交,29 文件 +1819−44)

按 issue 三项必改 + 新相位落地:

  1. 抽取器三处(src/entities/extractor.js):白名单加 affective_context、prompt 枚举同步并说明何时该用、未知类型不再静默降级(warn 带原始类型名);
  2. 新 sleep 相位(情感归因):不接 phaseRelations(其语义是「把孤立实体连上」,没有情感输入端),新开相位、共用 applyDecisions 校验(maxCreatePerRun / maxArchivePerRun 限额照旧);
    • 62a3b3f 相位本体 + affective_context 关系类型;
    • f58ac22 补四个审计洞;
    • 64a17a0 去重键改「情绪 + 证据重叠」;
    • 268cc53 同场合情绪按会话合并而不是丢弃;
    • ed02e12 merge-only 轮在 receipt 里也算一次写入;
    • 746383d 目标写进特质标题,两个目标不再撞行;
    • 2cdcc25 + 26ddd36 pattern 行刷新而不是追加——刷新后的 pattern 可计数、可审计、不 unborn;
  3. settings.js 新键成对入白名单、lang.js 双语文案、store 侧配合。

验证

node --test test/*.test.js → 3177 tests / 3176 pass / 0 fail / 1 skipped(test/sleep.test.js +511 行);check-sync 118 一致。

基点 = 当前 main(514d238),可干净合并。存储层无需 schema 变更(relation_type TEXT NOT NULL 无 CHECK,decisions.js 已有直写 supersedes 先例)——与 issue 实测一致。

…t relation type (#619)

Emotions were not representable: memories has no affect columns and
entity_relations only knew uses / depends_on / part_of / related_to, so a
situational reaction and a lasting trait landed as the same kind of row.
This adds the storage primitive plus a sleep phase that keeps the two apart.

Extractor (src/entities/extractor.js):
- VALID_RELATIONS gains affective_context.
- sanitizeRelationType no longer downgrades unknown types to related_to. It
  returns null, the relation is dropped, and the raw type name is reported in
  rejectedRelationTypes with a warn line. Silently rewriting an unknown
  relation type is how the graph grows edges nobody asked for: the write
  succeeds, no error surfaces, and the axis the caller wanted simply is not
  there.
- sanitizeExtractedData takes an optional logger to report those drops.

New phase (src/dream/sleep.js):
- phaseAffective runs after relations, behind sleepAffectiveEnabled (default
  off: no model call, no writes).
- The guard is in code, not in the prompt. The model may claim "trait"; the
  claim only survives if provenance shows the emotion across at least
  sleepAffectiveTraitMinSessions distinct sessions. One session is downgraded
  to occasion rather than discarded, and the downgrade count is returned.
- Occasions become affective_context edges carrying emotion, attribution, why,
  evidence, sessionCount and the downgrade marker. Traits go through
  validateDecisions + applyDecisions so they share the create budget.
- Unknown entities, evidence outside the scanned pool, empty emotion and the
  per-run relation cap are all reported in dropped with a reason instead of
  disappearing.

Config and docs: sleepAffectiveEnabled / sleepAffectiveTraitMinSessions /
sleepAffectiveMaxRelationsPerRun, documented in CONFIGURATION.md, SLEEP.md and
ENTITIES.md. The trait threshold is capped at a floor of 2 on purpose: N=1 is
the single occasion this whole axis exists to refuse.

Tests cover the accepts / rejects / non-string paths for the extractor and, for
the phase, disabled, downgrade, mint, dropped-reporting and the invariant that
no related_to edge appears. Mutation-checked: restoring the old silent
downgrade, forcing downgraded to false, or renaming the relation type each turn
the suite red.
The phase wrote relation edges directly, so nothing that counted decisions
saw them: the receipt reported `:0` for a run that did write, an aborted
pass reported `noop` as if the whole pool had been examined, a rejected
trait decision reported `noop` as if there was nothing to mint, and
replaying the same segment stacked a second identical edge that inflated
sessionCount. Report edgeWrites separately, abort on signal even when
nothing was written, fail when validation rejects, and skip an edge that
already exists.

Also: affective_context is no longer accepted by the entity extractor. A
relation written during extraction has no session provenance, so it cannot
tell an occasion from a trait, and it carries none of the phase's metadata
— one table, two schemas. The extractor now rejects it and warns.

Trait title and body move into STR.affectiveTrait so the en prompt gets an
en title, and the placeholder substitution uses replaceAll: both prompts
have two N placeholders and only the first was filled.
…619)

The guard compared evidence[0] only, so the same edge came back whenever the
model reordered the evidence array -- and the model's order is unstable, while
the session count that the probe reads is computed from exactly that list.

It also treated a second emotion inside one memory as a duplicate, which is the
opposite of what this phase promises: a demoted claim is recorded, never thrown
away. Emotion is now part of the key, so a reordering still collapses to one
edge and two feelings about the same occasion stay two edges.

Verified: sleep 50/50, full suite 3168/3167/0/1, check-sync clean. Mutation
checks killed both new tests (revert to evidence[0] -> 2 red; drop emotion from
the key -> 1 red).
…ng them

The dedupe key was "evidence lists intersect", which is not the same thing as
"the same occasion". Two memories from one session each saying he was anxious
have disjoint evidence, so the guard wrote two edges for one occasion while the
session count -- the only number the probe actually reads -- stayed at one.

It was also lossy in the other direction: once an edge existed, the evidence was
frozen at whatever the first run saw. A later run that reached two sessions hit
the guard, got dropped, and left sessionCount at 1 forever. Anything computing
attribution coverage off that edge would keep reading a stale number.

The key now lives at the session layer: same (person, target, emotion) and the
two session sets intersect means one occasion. On a hit the evidence union and
the recomputed session count are written back to the existing edge instead of
being discarded, and reported as `merged` -- neither a new edge nor a drop.
Merging runs before the relation cap, since it adds no row.

store.updateRelationMetadata is new and touches only metadata; weight, source
and created_at still describe when the edge was first established.

Verified: sleep 52/52, full suite 3170/3169/0/1, check-sync clean. Mutations
killed both new cases (judge by evidence intersection -> 1 red; skip the
write-back -> 2 red).
A run whose only effect was extending an existing affective edge reported a
write count of zero, so the audit row read "ran and did nothing" — the same
failure as counting edge inserts alone, just reached by a different path.

Keep the receipt at eight fields and add merged into totalApplied instead of
appending a ninth segment: buildReceipt/parseReceipt is a shared contract with
organize.js and, per-kind detail belongs in dream_runs.phases rather than in
the receipt. The receipt only answers whether the run changed the store, and a
merge does change it.

Also give the aborted and failed returns the same mergedList as the normal
path. A consumer reading edge detail on those paths got undefined silently,
which is the kind of gap the audit surface exists to prevent.
…lide (#619)

Two traits about the same person and emotion but different objects minted one
pattern: the title was built from person and emotion only, and applyCreate goes
through saveWithDedupe, which matches on (type, title). The second decision was
judged a duplicate and skipped without an error or a failures entry, while
applied still reported it as written — a real finding disappearing behind a
return value claiming success.

The target belongs in the title anyway: without it the title never says what the
emotion is about.

Mark an existing occasion edge when the same emotion is later confirmed as a
trait, instead of leaving it to claim "one occasion" next to a pattern claiming
"several". The edge keeps its sessionCount because the edge means the occasion
was observed, which stays true; promotedToTrait records that it is now part of a
confirmed disposition. Nothing rewrote it before, so the two records disagreed
and could not converge on their own.
A create decision that hits an existing same-title row goes through
saveWithDedupe's merge branch, which appends the new body under a
timestamped separator. applyCreate only checked that *some* memory came
back, so a replay reported applied:1 while the pattern body grew
161 -> 355 -> 549 chars across three rounds. Pattern rows are what the
inject path reads, so the duplicate body and the repeated evidence
trailer were reaching the model.

Pattern keeps being whole-text: pass _overwrite so the row is rewritten
in place from plan. Only pattern gets this; other types still append on a
same-title collision, because overwriting a whole existing memory is a
bigger risk than appending to it.

A create that merged instead of minting now reports applied:0 +
merged:1 and commits as create_merged. The two sleep phases report
refreshed when a run rewrote rows without minting anything, and
deriveStatus counts refreshed as work, so such a run is no longer
recorded as a noop.
A refresh rewrites an existing pattern row instead of minting a new one.
That single fact was applied in one place and forgotten in two, so three
records disagreed about the same run.

- The receipt counted applied + edgeWrites + merged, and refreshedTraits
  was missing from that sum. A phase reporting refreshed could therefore
  leave the run receipt at 0: the phase said the store changed, the run
  said it did not. Count it. The per-kind split stays in dream_runs.phases;
  the receipt keeps its single write count and its eight-field format.
- buildOutcome had no branch for create_merged, so outcome.byId was empty
  for a run that only refreshed. A refresh was invisible to the audit row
  even though something was rewritten. Give both create actions a
  disposition: created, and created-refreshed for the in-place rewrite.
  That is the audit reading, not a receipt_chain row: a new row has no
  before/after to record, which is why memory_mints exists.
- applyCreate recorded a memory mint on the refresh path too, so the same
  pattern acquired a second birth record with a different run_id and the
  panel showed it as born twice. Merge and update already skip the mint
  table for this reason; create_merged now follows the same rule.

Tests drive the production functions: the refresh-only sleep run parses
its own receipt and asserts the write count, buildOutcome is called
directly for the new dispositions, and two applyDecisions rounds check
that the mint table keeps one row. Reverting each fix turns its test red.

Refs #619
Copilot AI lite review requested due to automatic review settings November 9, 2026 21:12
@coderabbitai

coderabbitai Bot commented Nov 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 514d238 and 26ddd36.

Files selected for processing: 29

  • dsh-mneme/CHANGELOG.md
  • dsh-mneme/docs/CONFIGURATION.md
  • dsh-mneme/docs/ENTITIES.md
  • dsh-mneme/docs/SLEEP.md
  • dsh-mneme/lib/client.js
  • dsh-mneme/lib/config.js
  • dsh-mneme/lib/dream.js
  • dsh-mneme/lib/dream/decisions.js
  • dsh-mneme/lib/dream/sleep.js
  • dsh-mneme/lib/entities/extractor.js
  • dsh-mneme/lib/lang.js
  • dsh-mneme/lib/service.js
  • dsh-mneme/lib/settings.js
  • dsh-mneme/lib/store.js
  • dsh-mneme/src/config.js
  • dsh-mneme/src/dream.js
  • dsh-mneme/src/dream/decisions.js
  • dsh-mneme/src/dream/sleep.js
  • dsh-mneme/src/entities/extractor.js
  • dsh-mneme/src/lang.js
  • dsh-mneme/src/service.js
  • dsh-mneme/src/settings.js
  • dsh-mneme/src/store.js
  • dsh-mneme/test/api.test.js
  • dsh-mneme/test/dream-maxtokens-fallback.test.js
  • dsh-mneme/test/entities.test.js
  • dsh-mneme/test/mint-source.test.js
  • dsh-mneme/test/sleep-action-set.test.js
  • dsh-mneme/test/sleep.test.js
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@codecov

codecov Bot commented Nov 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 28 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
dsh-mneme/src/dream/sleep.js 92.94% 28 Missing ⚠️

📢 Thoughts on this report? Let us know!

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