symfonic.capabilities.memory.phases.profile¶
profile ¶
Phase 5: a correction the user made is promoted onto their profile.
Moved here from symfonic.capabilities.memory.phases.profile, which now imports
it back: phase 5 is on the quick roster, and a roster the kernel composes
cannot reach into the legacy package.
Deep Sleep plan, Stage 2.
Replaces the legacy apply_soul_corrections (formerly in phases.py),
which was broken three ways at once:
- It wrote instance VALUES into
DomainTemplate.soul_schema-- adict[str, str]mapping field names to their EXPECTED TYPES, consumed byextraction.txtasSchema: {{SOUL_SCHEMA}}to tell the model what shape a SOUL node should have. Writing values in place of types corrupted the schema shown to the model on the next turn. - Its result was discarded: the engine call site passed a defensive
dict(domain.soul_schema)copy, mutated it in place, and never wrote it back --report.soul_updatescounted changes that were then garbage-collected. - It was a live cross-tenant accumulator: the scaffolder's worker
template built the dict ONCE before the tenant loop and passed the
SAME object to every tenant's
consolidator.run(). In-place mutation meant tenant A's profile values could leak into the dict handed to tenant B.
promote_profile_corrections fixes all three structurally rather than
by convention:
- It takes no caller-owned mutable dict in its signature at all -- there is nothing to accidentally share across tenants (kills bug 3).
- It is scope-keyed by construction: every read and write goes through
GraphMemoryStorewith the caller'sTenantScope, so a promotion under tenant A's scope structurally cannot touch tenant B's data. - It writes corrections onto the tenant's own
SOUL:node(s) in the SEMANTIC layer -- never into a type schema. The type schema (profile_fields) is READ ONLY, to learn which field names count as "profile" (kills bug 1). - The write IS the result (a graph mutation) -- there is no return value to discard (kills bug 2).
PROMOTED_FIELD_SOURCE
module-attribute
¶
Provenance recorded against a field this phase promoted.
The prompt block renders each profile field with its own provenance, so a role the user corrected today must not present the onboarding form's three-month-old stamp inherited from the node it landed on. Writing the field's real source here is what makes the rendered attribution true -- and provenance is recorded, never invented, so it is written at the moment of the write rather than reconstructed at render time.
promote_profile_corrections
async
¶
promote_profile_corrections(graph: GraphMemoryStore, scope: TenantScope, recent_nodes: list[MemoryNode], profile_fields: frozenset[str], now: datetime | None = None) -> int
Promote user-corrected profile facts onto the tenant's SOUL node.
Trigger condition (kept verbatim from the pre-redesign
apply_soul_corrections, phases.py:446): only nodes carrying
properties["_last_edited_by"] == "user_manual_edit" are treated as
corrections. This keeps agent self-edits (extraction writes, this
phase's own promotion writes, etc.) out of the promotion loop --
load-bearing, do not relax without also revisiting the idempotency
guarantee below.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
graph
|
GraphMemoryStore
|
Tenant-scoped graph store. Both the read (locating the
canonical SOUL node) and the write (applying corrections) go
through this store with |
required |
scope
|
TenantScope
|
Tenant isolation scope for every graph operation. |
required |
recent_nodes
|
list[MemoryNode]
|
Nodes considered for this consolidation pass
(typically |
required |
profile_fields
|
frozenset[str]
|
The set of field names that constitute "profile"
for this domain. Callers derive this from
|
required |
now
|
datetime | None
|
Instant recorded as each promoted field's provenance. Defaults to wall-clock UTC. Injectable because a caller that pins its own clock -- a test, a replay, a walkthrough -- would otherwise write a stamp it cannot predict, and the rendered provenance would read as being from the future. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
Count of profile fields whose value actually changed on the |
int
|
canonical SOUL node (mirrors the legacy |
int
|
"count of key assignments made" semantics). A guard-marker-only |
int
|
rewrite (see idempotency note below) does not increment this |
int
|
count even though a graph write occurs. |
Idempotency: when the correction source IS the canonical SOUL node
(a human edited it directly rather than via a separate correction
record), its own _last_edited_by guard is flipped from
"user_manual_edit" to PROMOTION_MARKER even if the "corrected"
values already match (i.e. even when the differential count is zero).
Without this, the node would satisfy the guard again on the very next
pass -- reading this promotion's own prior output back as a fresh
user correction and re-promoting forever.
Source code in src/symfonic/capabilities/memory/phases/profile.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | |