Repository navigation
feat(sleep): 情感归因相位 + affective_context 关系类型(#619) - #628
modusensus wants to merge 9 commits into
Conversation
…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
|
Warning Review limit reachedYou'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. View limit details
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. |
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
背景
#619:误归因探针(PREREGISTRATION.md)的
structured臂依赖一个带 attribution 的情感关系类型affective_context,用来区分「情境状态(situational)」与「稳定特质(trait)」。此前src/零命中:抽取器白名单只有四种关系、未知类型静默降级成related_to(写入成功、图上没有情感轴)。改动(9 个提交,29 文件 +1819−44)
按 issue 三项必改 + 新相位落地:
src/entities/extractor.js):白名单加affective_context、prompt 枚举同步并说明何时该用、未知类型不再静默降级(warn 带原始类型名);phaseRelations(其语义是「把孤立实体连上」,没有情感输入端),新开相位、共用applyDecisions校验(maxCreatePerRun/maxArchivePerRun限额照旧);62a3b3f相位本体 +affective_context关系类型;f58ac22补四个审计洞;64a17a0去重键改「情绪 + 证据重叠」;268cc53同场合情绪按会话合并而不是丢弃;ed02e12merge-only 轮在 receipt 里也算一次写入;746383d目标写进特质标题,两个目标不再撞行;2cdcc25+26ddd36pattern 行刷新而不是追加——刷新后的 pattern 可计数、可审计、不 unborn;settings.js新键成对入白名单、lang.js双语文案、store 侧配合。验证
node --test test/*.test.js→ 3177 tests / 3176 pass / 0 fail / 1 skipped(test/sleep.test.js+511 行);check-sync118 一致。基点 = 当前 main(514d238),可干净合并。存储层无需 schema 变更(
relation_type TEXT NOT NULL无 CHECK,decisions.js已有直写supersedes先例)——与 issue 实测一致。