@adam0white/human-framework 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/person.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * SCOPE: the composite. `createPerson` wires every faculty's constructor; `tick` advances all faculties in
3
3
  * closed form from `p.now` to a later minute under the current activity's body load (idle awake otherwise),
4
- * splitting at the activity's end; `perceive` runs attention, then beliefs, relationships, judgement of
5
- * others' acts, memory and appraisal; `decide` scores offers and resolves the will (`answerNow` answers a
6
- * suggestion immediately); `begin` and `finish` bracket an activity and route the outcome into body, needs,
7
- * skills, habits, memory, conscience, agenda, relationships and trust in advising voices. Direct control is
8
- * `command`, `releaseCommand` and `previewCommand`; `knockDown` and `standUp` move a person in and out of the
9
- * downed state; `observeSkill` is observational learning; `setRetention` sets the per-person retention
4
+ * splitting at the activity's end and the absolute hourly grid; `perceive` runs attention, then beliefs,
5
+ * relationships, judgement of others' acts, memory and appraisal; `decide` scores offers and resolves the
6
+ * will (`answerNow` answers a suggestion immediately); `begin` and `finish` bracket an activity and route the
7
+ * outcome into body, needs, skills, habits, memory, conscience, agenda, relationships and trust in advising
8
+ * voices. Direct control is `command`, `releaseCommand` and `previewCommand`; `knockDown` and `standUp`
9
+ * move a person in and out of the downed state; `observeSkill` is observational learning; `setRetention` sets the per-person retention
10
10
  * bounds; `readPerson` is the read-only summary. Snapshot and restore live in `restore.ts`. This file owns
11
11
  * `now`, `activity`, `trace`, `nextDecision` and `retention`; every other slice is written only through its
12
12
  * owning module. It sequences the modules and makes no behavioural claims of its own beyond the engineering
@@ -1136,6 +1136,115 @@ export function learningFor(p, domain) {
1136
1136
  export function observeSkill(p, skill, minutes, modelLevel, domain) {
1137
1137
  return learnByWatching(p, skill, minutes, modelLevel, learningFor(p, domain), p.now);
1138
1138
  }
1139
+ /** Realized need changes, using readings captured before and after the outcome's deltas. */
1140
+ function realizedNeedDeltas(act, given, before, after) {
1141
+ const aff = act.affordance;
1142
+ const realized = {};
1143
+ for (const id of [...PHYSIOLOGICAL_NEEDS, ...PSYCHOLOGICAL_NEEDS]) {
1144
+ if (given[id] === undefined)
1145
+ continue;
1146
+ realized[id] =
1147
+ id === 'food' || id === 'water' || id === 'relief'
1148
+ ? levelOf(after, id) - levelOf(before, id)
1149
+ : given[id];
1150
+ }
1151
+ for (const id of ['sleep', 'rest']) {
1152
+ if (aff.advertises[id] !== undefined && given[id] === undefined) {
1153
+ realized[id] = levelOf(after, id) - (act.needsAtStart[id] ?? levelOf(before, id));
1154
+ }
1155
+ }
1156
+ return realized;
1157
+ }
1158
+ /** Record the deed and apply its appraisal, repentance, repair and regulation. */
1159
+ function respondToConscience(p, act, now, completed, minutes) {
1160
+ const aff = act.affordance;
1161
+ const deed = recordDeed(p, aff, act.intention, now, completed, act.necessity ? { necessity: true } : {});
1162
+ for (const ev of deed.appraisal)
1163
+ appraise(p, ev);
1164
+ const tags = aff.tags ?? [];
1165
+ if (completed && (tags.includes('worship') || tags.includes('repent'))) {
1166
+ // Worship closes breaches against God alone; guilt toward a wronged person waits for repair.
1167
+ const repented = new Set();
1168
+ for (const b of p.conscience.breaches) {
1169
+ if (!b.repaired && b.victimId === undefined && repent(p, b.id, now))
1170
+ repented.add(b.normId);
1171
+ }
1172
+ for (const b of p.conscience.breaches)
1173
+ if (!b.repaired && b.victimId !== undefined)
1174
+ repented.delete(b.normId);
1175
+ for (const normId of [...repented].sort())
1176
+ release(p, { id: 'guilt', causePrefix: `breach:${normId}` }, 0.6);
1177
+ regulate(p, minutes, 'worship');
1178
+ }
1179
+ if (completed && (tags.includes('repair') || tags.includes('apologize')) && aff.targetId !== undefined) {
1180
+ recordRepair(p, aff.targetId, now);
1181
+ release(p, { id: 'guilt', targetId: aff.targetId });
1182
+ socialEvent(p, { at: now, kind: 'apology', otherId: aff.targetId, byMe: true, magnitude: 0.7 });
1183
+ }
1184
+ if (act.mode === 'sleep' || tags.includes('rest') || tags.includes('reflection')) {
1185
+ regulate(p, minutes, tags.includes('reflection') ? 'reflection' : 'rest');
1186
+ }
1187
+ return { deed, tags };
1188
+ }
1189
+ /** Record relationships and character experience from a completed activity. */
1190
+ function recordParticipation(p, aff, tags, now, minutes) {
1191
+ const D = PERSON_DEFAULTS;
1192
+ if (p.character && (aff.with ?? []).some((o) => o !== p.id))
1193
+ noteCharacterSocial(p, minutes);
1194
+ const kind = tags.includes('work') ? 'shared-work' : 'chat';
1195
+ for (const other of aff.with ?? []) {
1196
+ if (other === p.id)
1197
+ continue;
1198
+ socialEvent(p, { at: now, kind, otherId: other, byMe: true, magnitude: D.participationMagnitude });
1199
+ }
1200
+ if (aff.targetId !== undefined && aff.targetId !== p.id) {
1201
+ const care = tags.includes('help') ? 'help' : tags.includes('gift') ? 'gift' : undefined;
1202
+ if (care)
1203
+ socialEvent(p, { at: now, kind: care, otherId: aff.targetId, byMe: true, magnitude: 0.7 });
1204
+ }
1205
+ }
1206
+ /** Learn from each advising voice after the activity's conscience and agenda results are known. */
1207
+ function learnActivityVoices(p, act, after, now, felt, broken, breached) {
1208
+ const aff = act.affordance;
1209
+ const D = PERSON_DEFAULTS;
1210
+ // Trust in every voice that pushed this activity: how it felt, minus bodily harm the host's deltas do not
1211
+ // show (needs that fell during the activity and are now urgent).
1212
+ const voices = act.suggestions ?? (act.suggestion ? [act.suggestion] : []);
1213
+ if (voices.length > 0) {
1214
+ let harm = 0;
1215
+ for (const id of ['food', 'water', 'sleep', 'rest']) {
1216
+ if (urgencyOf(after, id) < D.voiceHarmUrgency)
1217
+ continue;
1218
+ harm += Math.max(0, (act.needsAtStart[id] ?? levelOf(after, id)) - levelOf(after, id));
1219
+ }
1220
+ // Doing what a voice pushed broke a commitment or the person's own standards: that costs the voice as harm
1221
+ // does (review 2026-10-03: thirty broken fasts under insistence had moved trust only 0.50 -> 0.41). Omissions
1222
+ // the push caused later (a missed prayer during a pushed activity) are not attributed here.
1223
+ let breach = 0;
1224
+ for (const c of broken)
1225
+ breach += D.missedDistress * c.importance;
1226
+ breach += D.missedDistress * 0.5 * breached;
1227
+ for (const res of voices) {
1228
+ const protest = res.verdict === 'complied' || res.verdict === 'commanded';
1229
+ const reason = breach > 0
1230
+ ? protest
1231
+ ? 'breach-under-protest'
1232
+ : 'breach'
1233
+ : harm > 0
1234
+ ? protest
1235
+ ? 'harm-under-protest'
1236
+ : 'harm'
1237
+ : felt >= 0
1238
+ ? 'went-well'
1239
+ : 'went-badly';
1240
+ learnFromVoice(p, res, clampSigned(felt - D.voiceHarmGain * harm - breach), {
1241
+ at: now,
1242
+ action: aff.action,
1243
+ reason,
1244
+ });
1245
+ }
1246
+ }
1247
+ }
1139
1248
  /**
1140
1249
  * Apply what happened. Physiological deltas go to the body, psychological ones to the needs, then skills,
1141
1250
  * habits, memory (expectation learning and an episode), conscience (deed, breaches, repair), agenda,
@@ -1179,20 +1288,7 @@ export function finish(p, outcome, opts = {}) {
1179
1288
  // Realized deltas: what the body actually took in for food/water/relief (a drink at full hydration
1180
1289
  // realizes nothing), host values for psychological needs, and measured since the start for sleep/rest.
1181
1290
  const after = readNeeds(p, readBodyOf(p));
1182
- const realized = {};
1183
- for (const id of [...PHYSIOLOGICAL_NEEDS, ...PSYCHOLOGICAL_NEEDS]) {
1184
- if (given[id] === undefined)
1185
- continue;
1186
- realized[id] =
1187
- id === 'food' || id === 'water' || id === 'relief'
1188
- ? levelOf(after, id) - levelOf(before, id)
1189
- : given[id];
1190
- }
1191
- for (const id of ['sleep', 'rest']) {
1192
- if (aff.advertises[id] !== undefined && given[id] === undefined) {
1193
- realized[id] = levelOf(after, id) - (act.needsAtStart[id] ?? levelOf(before, id));
1194
- }
1195
- }
1291
+ const realized = realizedNeedDeltas(act, given, before, after);
1196
1292
  // Felt valence.
1197
1293
  let felt = 0;
1198
1294
  for (const id of Object.keys(realized)) {
@@ -1255,32 +1351,7 @@ export function finish(p, outcome, opts = {}) {
1255
1351
  episode.voiceId = act.suggestion.voiceId;
1256
1352
  remember(p, episode);
1257
1353
  // Conscience.
1258
- const deed = recordDeed(p, aff, act.intention, now, completed, act.necessity ? { necessity: true } : {});
1259
- for (const ev of deed.appraisal)
1260
- appraise(p, ev);
1261
- const tags = aff.tags ?? [];
1262
- if (completed && (tags.includes('worship') || tags.includes('repent'))) {
1263
- // Worship closes breaches against God alone; guilt toward a wronged person waits for repair.
1264
- const repented = new Set();
1265
- for (const b of p.conscience.breaches) {
1266
- if (!b.repaired && b.victimId === undefined && repent(p, b.id, now))
1267
- repented.add(b.normId);
1268
- }
1269
- for (const b of p.conscience.breaches)
1270
- if (!b.repaired && b.victimId !== undefined)
1271
- repented.delete(b.normId);
1272
- for (const normId of [...repented].sort())
1273
- release(p, { id: 'guilt', causePrefix: `breach:${normId}` }, 0.6);
1274
- regulate(p, minutes, 'worship');
1275
- }
1276
- if (completed && (tags.includes('repair') || tags.includes('apologize')) && aff.targetId !== undefined) {
1277
- recordRepair(p, aff.targetId, now);
1278
- release(p, { id: 'guilt', targetId: aff.targetId });
1279
- socialEvent(p, { at: now, kind: 'apology', otherId: aff.targetId, byMe: true, magnitude: 0.7 });
1280
- }
1281
- if (act.mode === 'sleep' || tags.includes('rest') || tags.includes('reflection')) {
1282
- regulate(p, minutes, tags.includes('reflection') ? 'reflection' : 'rest');
1283
- }
1354
+ const { deed, tags } = respondToConscience(p, act, now, completed, minutes);
1284
1355
  // Agenda.
1285
1356
  const routed = { ...outcome };
1286
1357
  if (routed.fulfills === undefined && aff.fulfills !== undefined)
@@ -1303,58 +1374,10 @@ export function finish(p, outcome, opts = {}) {
1303
1374
  }
1304
1375
  }
1305
1376
  // Relationships from taking part together.
1306
- if (completed) {
1307
- if (p.character && (aff.with ?? []).some((o) => o !== p.id))
1308
- noteCharacterSocial(p, minutes);
1309
- const kind = tags.includes('work') ? 'shared-work' : 'chat';
1310
- for (const other of aff.with ?? []) {
1311
- if (other === p.id)
1312
- continue;
1313
- socialEvent(p, { at: now, kind, otherId: other, byMe: true, magnitude: D.participationMagnitude });
1314
- }
1315
- if (aff.targetId !== undefined && aff.targetId !== p.id) {
1316
- const care = tags.includes('help') ? 'help' : tags.includes('gift') ? 'gift' : undefined;
1317
- if (care)
1318
- socialEvent(p, { at: now, kind: care, otherId: aff.targetId, byMe: true, magnitude: 0.7 });
1319
- }
1320
- }
1321
- // Trust in every voice that pushed this activity: how it felt, minus bodily harm the host's deltas do not
1322
- // show (needs that fell during the activity and are now urgent).
1323
- const voices = act.suggestions ?? (act.suggestion ? [act.suggestion] : []);
1324
- if (voices.length > 0) {
1325
- let harm = 0;
1326
- for (const id of ['food', 'water', 'sleep', 'rest']) {
1327
- if (urgencyOf(after, id) < D.voiceHarmUrgency)
1328
- continue;
1329
- harm += Math.max(0, (act.needsAtStart[id] ?? levelOf(after, id)) - levelOf(after, id));
1330
- }
1331
- // Doing what a voice pushed broke a commitment or the person's own standards: that costs the voice as harm
1332
- // does (review 2026-10-03: thirty broken fasts under insistence had moved trust only 0.50 -> 0.41). Omissions
1333
- // the push caused later (a missed prayer during a pushed activity) are not attributed here.
1334
- let breach = 0;
1335
- for (const c of broken)
1336
- breach += D.missedDistress * c.importance;
1337
- breach += D.missedDistress * 0.5 * deed.breached.length;
1338
- for (const res of voices) {
1339
- const protest = res.verdict === 'complied' || res.verdict === 'commanded';
1340
- const reason = breach > 0
1341
- ? protest
1342
- ? 'breach-under-protest'
1343
- : 'breach'
1344
- : harm > 0
1345
- ? protest
1346
- ? 'harm-under-protest'
1347
- : 'harm'
1348
- : felt >= 0
1349
- ? 'went-well'
1350
- : 'went-badly';
1351
- learnFromVoice(p, res, clampSigned(felt - D.voiceHarmGain * harm - breach), {
1352
- at: now,
1353
- action: aff.action,
1354
- reason,
1355
- });
1356
- }
1357
- }
1377
+ if (completed)
1378
+ recordParticipation(p, aff, tags, now, minutes);
1379
+ // Voice learning follows both conscience and agenda, so it includes their breaches.
1380
+ learnActivityVoices(p, act, after, now, felt, broken, deed.breached.length);
1358
1381
  // Chronicle, while the activity is still current.
1359
1382
  const noted = { kept: kept.map((c) => c.id) };
1360
1383
  if (act.suggestion)
@@ -26,8 +26,17 @@ export declare const PROMPTINGS_DEFAULTS: {
26
26
  * keeps its practice and last use (the spec's `practice` only seeds new responses). Returns the state.
27
27
  */
28
28
  export declare function enablePromptings(p: Person, spec?: PromptingsSpec): PromptingsState;
29
- /** Practice of a response at `now`, after disuse decay. Pure. */
30
- export declare function practiceAt(r: PromptingResponse, now: Minute): Unit;
29
+ /** Practice of a response at `now`, after disuse decay (`halfLifeDays` defaults to 60, as before 2.9.0). Pure. */
30
+ export declare function practiceAt(r: PromptingResponse, now: Minute, halfLifeDays?: number): Unit;
31
+ /** What a prompt offers a cue to match (2.9.0). */
32
+ export interface PromptContext {
33
+ action: string;
34
+ tags?: readonly string[] | undefined;
35
+ /** The opposing term's source: 'norm:x' or 'goal:g'. */
36
+ opposedBy: string;
37
+ }
38
+ /** Whether a response's cue matches a prompt: its action, one of its tags, or the opposing norm or goal. Pure. */
39
+ export declare function responseCueMatches(cue: string, at: PromptContext): boolean;
31
40
  /** Whether an option's terms carry a noticed prompt: its strongest pull and the opposing term. Pure. */
32
41
  export declare function promptIn(terms: readonly Term[], tags: readonly string[] | undefined): {
33
42
  source: string;
@@ -46,6 +55,8 @@ export interface PromptingSeen {
46
55
  opposedBy: string;
47
56
  /** Whether a turning-away term was applied. */
48
57
  turnApplied: boolean;
58
+ /** The offer's tags (2.9.0), for matching a response's cue. */
59
+ tags?: readonly string[] | undefined;
49
60
  }
50
61
  /** What `notePromptings` did, for the composite (the `fault` guilt goes through affect). */
51
62
  export interface PromptingsNoted {
@@ -68,10 +79,12 @@ export interface PromptingsReadingView {
68
79
  turned: number;
69
80
  passed: number;
70
81
  assented: number;
82
+ /** `cue` only when the response has one (2.9.0). */
71
83
  responses: {
72
84
  id: string;
73
85
  kind: PromptingResponseKind;
74
86
  practice: Unit;
87
+ cue?: string;
75
88
  }[];
76
89
  /** Vocabulary for the last few prompts, newest last. */
77
90
  lines: string[];
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Detection reads an option's traced terms and changes none of them: the pull is the sum of positive `habit`,
10
10
  * `emotion:*`, `need:*` and `promptings:echo` terms (every positive term on an offer tagged `temptation`); it is a
11
- * prompt when the pull reaches `noticeAt` while a `norm:*` or `goal:*` term on the same option is negative.
11
+ * prompt when the pull reaches `noticeAt` while a `norm:*` or `goal:*` term on the same option is negative (goal
12
+ * terms are never negative, see `agendaTerms`, so in practice the opposition is a held norm).
12
13
  * Abstention windows are not detected (they veto, and carry no term). A person with a response gets one extra
13
14
  * term, `promptings:turn` = -turnMax × practice, on that option; it is bounded and absent at survival desperation,
14
15
  * so a desperate bodily need still wins (2:286). Each prompt is recorded once per decision point (a review that
@@ -16,6 +17,16 @@
16
17
  * or `passed` (not chosen, no response). Practice grows with diminishing gains once per decision point where the
17
18
  * response was used (more when it held; a vetoed option earns none, since the will blocked it), and halves over `practiceHalfLifeDays` of disuse.
18
19
  *
20
+ * Cued responses and host time (2.9.0, both opt-in): a response may carry a `cue` (an action, an offer tag, or the
21
+ * opposing norm or goal), for a phrase learned at one pull. At a prompt the person uses the most practised response
22
+ * whose cue matches it; when none matches, the most practised response without a cue. A cued response never serves
23
+ * a prompt it does not match, so a phrase learned at the alarm does not also turn the person from gossip (the
24
+ * literal "global best" fallback would bring that back). Each response used at a decision point gains once. Without
25
+ * cues every response is uncued and this is the 2.4.0 rule exactly. `practiceHalfLifeDays` in `enablePromptings`
26
+ * replaces the 60-day half-life for the whole person (a one-week game can let a phrase fade); it is one setting per
27
+ * person, not per response, because the need is the host's time scale and no source gives phrases different decay.
28
+ * Practice is decayed lazily from `lastUsedAt`, so a new half-life also applies to disuse already elapsed.
29
+ *
19
30
  * Held readings (reconciliation.md §5): `whisper`, `inclination`, `intrusion` and `none` give identical decisions
20
31
  * and differ only in the vocabulary of `promptingLine`. `fault` (the scrupulous reading, which both the tradition
21
32
  * and clinicians reject) adds a small guilt emotion at the prompt itself, from the person's own understanding;
@@ -71,7 +82,14 @@ export function enablePromptings(p, spec = {}) {
71
82
  continue;
72
83
  const held = prev?.responses.find((x) => x.id === r.id);
73
84
  const seed = Number.isFinite(r.practice) ? clamp01(r.practice) : 0.1;
74
- responses.push(held ? { ...held, kind: r.kind } : { id: r.id, kind: r.kind, practice: seed, lastUsedAt: p.now });
85
+ const next = held
86
+ ? { ...held, kind: r.kind }
87
+ : { id: r.id, kind: r.kind, practice: seed, lastUsedAt: p.now };
88
+ // The spec's cue wins; a response re-enabled without one loses its old cue.
89
+ delete next.cue;
90
+ if (cueOk(r.cue))
91
+ next.cue = r.cue;
92
+ responses.push(next);
75
93
  }
76
94
  const state = {
77
95
  reading: spec.reading && READINGS.includes(spec.reading) ? spec.reading : 'none',
@@ -82,22 +100,44 @@ export function enablePromptings(p, spec = {}) {
82
100
  };
83
101
  if (spec.readingSource !== undefined)
84
102
  state.readingSource = spec.readingSource;
103
+ if (halfLifeOk(spec.practiceHalfLifeDays))
104
+ state.practiceHalfLifeDays = spec.practiceHalfLifeDays;
85
105
  p.promptings = state;
86
106
  return state;
87
107
  }
88
- /** Practice of a response at `now`, after disuse decay. Pure. */
89
- export function practiceAt(r, now) {
90
- return clamp01(halve(r.practice, now - r.lastUsedAt, PROMPTINGS_DEFAULTS.practiceHalfLifeDays));
108
+ const cueOk = (x) => typeof x === 'string' && x !== '' && x.length <= 200;
109
+ const halfLifeOk = (x) => typeof x === 'number' && Number.isFinite(x) && x > 0 && x <= 36500;
110
+ const halfLifeOf = (s) => s.practiceHalfLifeDays ?? PROMPTINGS_DEFAULTS.practiceHalfLifeDays;
111
+ /** Practice of a response at `now`, after disuse decay (`halfLifeDays` defaults to 60, as before 2.9.0). Pure. */
112
+ export function practiceAt(r, now, halfLifeDays = PROMPTINGS_DEFAULTS.practiceHalfLifeDays) {
113
+ return clamp01(halve(r.practice, now - r.lastUsedAt, halfLifeDays));
114
+ }
115
+ /** Whether a response's cue matches a prompt: its action, one of its tags, or the opposing norm or goal. Pure. */
116
+ export function responseCueMatches(cue, at) {
117
+ if (cue === at.action || cue === at.opposedBy || (at.tags?.includes(cue) ?? false))
118
+ return true;
119
+ const colon = at.opposedBy.indexOf(':');
120
+ return colon >= 0 && cue === at.opposedBy.slice(colon + 1);
91
121
  }
92
- /** The response a person would use now: highest practice, ties by id. */
93
- function bestResponse(s, now) {
94
- let best;
122
+ /**
123
+ * The response a person would use now at prompt `at`: the most practised whose cue matches, else the most
124
+ * practised without a cue; ties by id. Without cues this is the most practised response (2.4.0).
125
+ */
126
+ function bestResponse(s, now, at) {
127
+ let cued;
128
+ let plain;
129
+ const better = (b, r, x) => !b || x > b.practice || (x === b.practice && r.id < b.r.id);
130
+ const half = halfLifeOf(s);
95
131
  for (const r of s.responses) {
96
- const practice = practiceAt(r, now);
97
- if (!best || practice > best.practice || (practice === best.practice && r.id < best.r.id))
98
- best = { r, practice };
132
+ const practice = practiceAt(r, now, half);
133
+ if (r.cue === undefined) {
134
+ if (better(plain, r, practice))
135
+ plain = { r, practice };
136
+ }
137
+ else if (responseCueMatches(r.cue, at) && better(cued, r, practice))
138
+ cued = { r, practice };
99
139
  }
100
- return best;
140
+ return cued ?? plain;
101
141
  }
102
142
  const isPull = (source, tagged) => tagged ||
103
143
  source === 'habit' ||
@@ -145,10 +185,13 @@ export function promptingTerms(p, action, terms, tags, now, desperation) {
145
185
  }
146
186
  if (desperation >= PROMPTINGS_DEFAULTS.survivalDesperation)
147
187
  return out;
148
- const best = bestResponse(s, now);
149
- if (!best || best.practice <= 0)
188
+ if (s.responses.length === 0)
150
189
  return out;
151
- if (!promptIn([...terms, ...out], tags))
190
+ const seen = promptIn([...terms, ...out], tags);
191
+ if (!seen)
192
+ return out;
193
+ const best = bestResponse(s, now, { action, tags, opposedBy: seen.opposedBy });
194
+ if (!best || best.practice <= 0)
152
195
  return out;
153
196
  const v = round(-PROMPTINGS_DEFAULTS.turnMax * best.practice);
154
197
  if (v < 0)
@@ -165,11 +208,12 @@ export function notePromptings(p, seen, chosenId, now) {
165
208
  if (!s || seen.length === 0)
166
209
  return noted;
167
210
  const D = PROMPTINGS_DEFAULTS;
168
- const best = bestResponse(s, now);
169
- // One practice gain per decision point, however many options carried a prompt: the larger one if any held.
170
- let gain = 0;
211
+ // One practice gain per response per decision point, however many options it served: the larger one if any held.
212
+ // Responses are chosen before any practice moves, as `promptingTerms` chose them.
213
+ const gains = new Map();
171
214
  for (const x of seen) {
172
215
  const assented = x.affordanceId === chosenId;
216
+ const best = bestResponse(s, now, x);
173
217
  const used = x.turnApplied && best !== undefined;
174
218
  const rec = {
175
219
  at: now,
@@ -181,7 +225,7 @@ export function notePromptings(p, seen, chosenId, now) {
181
225
  };
182
226
  if (used && best) {
183
227
  rec.responseId = best.r.id;
184
- gain = Math.max(gain, assented ? D.gainAttempt : D.gainTurned);
228
+ gains.set(best.r, Math.max(gains.get(best.r) ?? 0, assented ? D.gainAttempt : D.gainTurned));
185
229
  }
186
230
  s.recent.push(rec);
187
231
  noted.records.push(rec);
@@ -194,10 +238,12 @@ export function notePromptings(p, seen, chosenId, now) {
194
238
  }
195
239
  }
196
240
  }
197
- if (best && gain > 0) {
198
- const practice = practiceAt(best.r, now);
199
- best.r.practice = round(clamp01(practice + gain * (1 - practice)));
200
- best.r.lastUsedAt = now;
241
+ for (const [r, gain] of gains) {
242
+ if (gain <= 0)
243
+ continue;
244
+ const practice = practiceAt(r, now, halfLifeOf(s));
245
+ r.practice = round(clamp01(practice + gain * (1 - practice)));
246
+ r.lastUsedAt = now;
201
247
  }
202
248
  if (s.recent.length > D.maxRecent)
203
249
  s.recent.splice(0, s.recent.length - D.maxRecent);
@@ -249,7 +295,12 @@ export function promptingsReading(p, now = p.now) {
249
295
  turned: count('turned'),
250
296
  passed: count('passed'),
251
297
  assented: count('assented'),
252
- responses: s.responses.map((r) => ({ id: r.id, kind: r.kind, practice: round(practiceAt(r, now)) })),
298
+ responses: s.responses.map((r) => ({
299
+ id: r.id,
300
+ kind: r.kind,
301
+ practice: round(practiceAt(r, now, halfLifeOf(s))),
302
+ ...(r.cue !== undefined ? { cue: r.cue } : {}),
303
+ })),
253
304
  lines: s.recent.slice(-5).map((r) => promptingLine(s.reading, r.outcome, kindOf(r.responseId))),
254
305
  };
255
306
  if (s.readingSource !== undefined)
@@ -298,12 +349,15 @@ export function sanitizePromptings(input, now) {
298
349
  continue;
299
350
  if (!minute(r.lastUsedAt) || r.lastUsedAt > now || responses.some((x) => x.id === r.id))
300
351
  continue;
301
- responses.push({
352
+ const kept = {
302
353
  id: r.id,
303
354
  kind: r.kind,
304
355
  practice: r.practice,
305
356
  lastUsedAt: r.lastUsedAt,
306
- });
357
+ };
358
+ if (cueOk(r.cue))
359
+ kept.cue = r.cue;
360
+ responses.push(kept);
307
361
  }
308
362
  const recent = [];
309
363
  for (const r of input.recent.slice(-D.maxRecent)) {
@@ -344,5 +398,7 @@ export function sanitizePromptings(input, now) {
344
398
  };
345
399
  if (str(input.readingSource))
346
400
  out.readingSource = input.readingSource;
401
+ if (halfLifeOk(input.practiceHalfLifeDays))
402
+ out.practiceHalfLifeDays = input.practiceHalfLifeDays;
347
403
  return out;
348
404
  }
@@ -260,9 +260,15 @@ export function sanitizeProspection(input, now, personId) {
260
260
  !minute(r.receivedAt) ||
261
261
  r.receivedAt > now)
262
262
  continue;
263
- if (revisions.some((old) => identity(old, r)))
263
+ const validated = {
264
+ sourceId: r.sourceId,
265
+ id: r.id,
266
+ revision: r.revision,
267
+ receivedAt: r.receivedAt,
268
+ };
269
+ if (revisions.some((old) => identity(old, validated)))
264
270
  return undefined;
265
- revisions.push({ sourceId: r.sourceId, id: r.id, revision: r.revision, receivedAt: r.receivedAt });
271
+ revisions.push(validated);
266
272
  }
267
273
  const valid = [];
268
274
  for (const e of entries) {
package/dist/restore.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { Person } from './types.ts';
2
2
  /** A deep copy of the person as plain JSON (what a host saves). */
3
3
  export declare function snapshot(p: Person): Person;
4
+ /**
5
+ * A deterministic 16-hex-digit digest of the canonical saved person. Canonicalization runs the saved form
6
+ * through `restore` (including migrations, defaults, bounds and optional-slice sanitizers), then sorts every
7
+ * object's keys recursively; array order stays significant. Thus key insertion order and empty optional slices
8
+ * that `restore` drops do not change the digest. Includes the engine stamp. Reads only; never mutates `p`.
9
+ * Uses 64-bit FNV-1a over JSON's UTF-16 code units, with no platform or crypto dependency. This is a change
10
+ * detector, not a cryptographic integrity check; collisions are possible. Invalid saves throw as in `restore`.
11
+ */
12
+ export declare function fingerprint(p: Person): string;
4
13
  /**
5
14
  * Validate a saved person and fill defaults for missing or mistyped fields, slice by slice and field by
6
15
  * field. A save from an earlier supported engine is upgraded by `migrate` first. Throws on a wrong schema, a
package/dist/restore.js CHANGED
@@ -31,6 +31,26 @@ import { sanitizeWill } from './will/index.js';
31
31
  export function snapshot(p) {
32
32
  return structuredClone(p);
33
33
  }
34
+ /**
35
+ * A deterministic 16-hex-digit digest of the canonical saved person. Canonicalization runs the saved form
36
+ * through `restore` (including migrations, defaults, bounds and optional-slice sanitizers), then sorts every
37
+ * object's keys recursively; array order stays significant. Thus key insertion order and empty optional slices
38
+ * that `restore` drops do not change the digest. Includes the engine stamp. Reads only; never mutates `p`.
39
+ * Uses 64-bit FNV-1a over JSON's UTF-16 code units, with no platform or crypto dependency. This is a change
40
+ * detector, not a cryptographic integrity check; collisions are possible. Invalid saves throw as in `restore`.
41
+ */
42
+ export function fingerprint(p) {
43
+ const json = JSON.stringify(restore(snapshot(p)), (_key, value) => {
44
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
45
+ return value;
46
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
47
+ });
48
+ let hash = 0xcbf29ce484222325n;
49
+ for (let i = 0; i < json.length; i++) {
50
+ hash = BigInt.asUintN(64, (hash ^ BigInt(json.charCodeAt(i))) * 0x100000001b3n);
51
+ }
52
+ return hash.toString(16).padStart(16, '0');
53
+ }
34
54
  // Unlike core `isObj`, arrays pass: a required slice saved as an array is filled from defaults, not refused.
35
55
  const isObject = (x) => typeof x === 'object' && x !== null;
36
56
  const isMinute = (x) => isNum(x) && Math.abs(x) <= MAX_MINUTE;
@@ -3,7 +3,8 @@
3
3
  * the commitments the chronicle recorded as kept or broken since their last review, and for each one they broke
4
4
  * forms or renews a *resolve*: an if–then intention for that commitment's next occasion (Gollwitzer & Sheeran
5
5
  * 2006, research/reconciliation.md E1). While a matching commitment is pending, the resolve adds one bounded term,
6
- * `review:resolve` (each resolve counted once per option, the sum clamped to ±`resolveScale`), with the sign of the commitment's own term (toward a duty, away from an abstention's breaking
6
+ * `review:resolve` (each resolve counted once per option, the sum clamped to ±`resolveScale`; 2.9.0: the term's
7
+ * `ref` is the resolve's key when exactly one resolve weighed in), with the sign of the commitment's own term (toward a duty, away from an abstention's breaking
7
8
  * act), scaled by its strength. Strength grows with diminishing gains at each further lapse, relaxes once the
8
9
  * commitment is kept, and fades over `halfLifeDays` of disuse. Released and excused commitments (exemptions,
9
10
  * sleep, necessity) form no resolve: what was beyond capacity is not held against the person (2:286). The review
@@ -23,6 +24,11 @@
23
24
  * piety, repentance as a ruling (tawba conditions are not modelled), effects on sleep onset (Harvey's model, E6, is
24
25
  * not evidence enough), or that review improves outcomes beyond the borrowed if–then shape. It draws no randomness;
25
26
  * without `enableReview` nothing here runs and existing runs replay byte for byte.
27
+ *
28
+ * Host notes (2.9.0, docs only). `reviewLine` sets commitment labels into "I didn't keep …" and "Today I kept …",
29
+ * so a label should be a noun phrase ("my call to Mum", "the evening prayer"), not a verb phrase ("calling Mum").
30
+ * A recurring commitment lapses on any day its window passes unlived: a host that skips a day (no service, a
31
+ * fast-forward) should still live, or release, that day's occasions, or the review counts each one as missed.
26
32
  */
27
33
  import { clamp01, dexp, isObj, minuteOfDay, round } from '../core/index.js';
28
34
  export const REVIEW_DEFAULTS = {
@@ -72,6 +78,7 @@ export function reviewTerms(p, terms) {
72
78
  return [];
73
79
  let value = 0;
74
80
  const used = new Set();
81
+ let only;
75
82
  for (const t of terms) {
76
83
  const m = /^(commitment|abstain):(.+)$/.exec(t.source);
77
84
  if (!m || t.value === 0)
@@ -83,11 +90,18 @@ export function reviewTerms(p, terms) {
83
90
  if (!r || used.has(r.key))
84
91
  continue;
85
92
  used.add(r.key);
93
+ only = r.key;
86
94
  value += Math.sign(t.value) * REVIEW_DEFAULTS.resolveScale * r.strength;
87
95
  }
88
96
  const max = REVIEW_DEFAULTS.resolveScale;
89
97
  value = Math.max(-max, Math.min(max, value));
90
- return value === 0 ? [] : [{ source: 'review:resolve', value: round(value) }];
98
+ if (value === 0)
99
+ return [];
100
+ const term = { source: 'review:resolve', value: round(value) };
101
+ // Name the resolve when exactly one weighed in (2.9.0, The Pass): "he turned from the jar, as he said he would".
102
+ if (used.size === 1 && only !== undefined)
103
+ term.ref = only;
104
+ return [term];
91
105
  }
92
106
  const fade = (s, days) => days <= 0 ? s : s * dexp((-Math.LN2 * days) / REVIEW_DEFAULTS.halfLifeDays);
93
107
  /**
package/dist/sim/sim.d.ts CHANGED
@@ -225,6 +225,7 @@ export declare function communityState(c: Community): CommunityState;
225
225
  /**
226
226
  * A community over `people`. With `prior` (from `communityState`, possibly through JSON), the host-side state is
227
227
  * resumed from it, so a run restored from people + community + world state continues exactly as it would have.
228
+ * `prior` is copied: after stepping, write back `communityState(c)`; the old `prior` stays stale.
228
229
  */
229
230
  export declare function createCommunity(people: Person[], prior?: CommunityState): Community;
230
231
  /** Add a person to a community (a newborn, an arrival); keeps the id order the driver relies on. */
@@ -237,9 +238,13 @@ export declare function birth(c: Community, a: Person, b: Person, spec: ChildSpe
237
238
  /**
238
239
  * Tell `dead`'s death at `at` to everyone alive in the community who has a tie to them (1.8.0): a told `death`
239
240
  * percept through `perceive`, so they grieve, the tie is kept and marked, and a spouse is widowed. Each listener's
240
- * clock must be at or before `at`; the drivers call it when they notice the death.
241
+ * receipt time is at least their own `now`. `late: 'now'` explicitly permits listeners past `at`, as does the
242
+ * existing default. `late: 'throw'` requires all eligible listeners to be at or before `at` and throws before
243
+ * delivering any news if one is ahead. This changes no listener's clock.
241
244
  */
242
- export declare function tellDeath(c: Community, dead: Person, at: Minute): void;
245
+ export declare function tellDeath(c: Community, dead: Person, at: Minute, opts?: {
246
+ late?: 'throw' | 'now';
247
+ }): void;
243
248
  /** Next minute at which this person needs attention. */
244
249
  export declare function nextEventAt(c: Community, p: Person): Minute;
245
250
  /**