@adam0white/human-framework 2.2.1 → 2.4.0

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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * SCOPE (promptings and turning away, 2.4.0): a *prompt* is a pull toward an option that the person's own held
3
+ * norms or goals oppose, strong enough to be noticed. This module records prompts, lets the person turn from them
4
+ * by a practised response, and keeps a prompt apart from assent: only an assented act reaches `conscience/
5
+ * recordDeed`, so a prompt is never a breach (Bukhari 5269, research/reconciliation.md §3). It is opt-in
6
+ * (`enablePromptings`, or `PersonSpec.enable.promptings`); without it nothing here runs and existing runs replay
7
+ * byte for byte. It draws no randomness.
8
+ *
9
+ * Detection reads an option's traced terms and changes none of them: the pull is the sum of positive `habit`,
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.
12
+ * Abstention windows are not detected (they veto, and carry no term). A person with a response gets one extra
13
+ * term, `promptings:turn` = -turnMax × practice, on that option; it is bounded and absent at survival desperation,
14
+ * so a desperate bodily need still wins (2:286). Each prompt is recorded once per decision point (a review that
15
+ * keeps the running activity records nothing) as `assented` (chosen), `turned` (not chosen, a response practised)
16
+ * or `passed` (not chosen, no response). Practice grows with diminishing gains once per decision point where the
17
+ * 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
+ * Held readings (reconciliation.md §5): `whisper`, `inclination`, `intrusion` and `none` give identical decisions
20
+ * and differ only in the vocabulary of `promptingLine`. `fault` (the scrupulous reading, which both the tradition
21
+ * and clinicians reject) adds a small guilt emotion at the prompt itself, from the person's own understanding;
22
+ * that guilt is an emotion, never a breach, so it changes decisions only where an offer acts through guilt's
23
+ * tendencies. With a host-set `echoRate` > 0, `fault` also gains a recurrence pull on that action
24
+ * (`promptings:echo`), decaying over days: a labelled assumption from contested suppression-rebound evidence
25
+ * (P3), off by default. No reading earns a hidden bonus.
26
+ *
27
+ * Borrowed shapes: everyday desire, conflict and resistance (P1, P2); suppression rebound and redirection (P3,
28
+ * P13); practised if–then responses (P5). Every number is an engineering default, not fitted. Does not claim: the
29
+ * source of any thought, a measure of faith, spiritual rank, reward for resisting (H4 is acknowledged as a held
30
+ * belief and never computed), clinical OCD, or a cessation rate. The lenses (`promptingsReading`, `nafsReading`)
31
+ * only describe existing state; they store and compute nothing new about the person.
32
+ */
33
+ import { clamp01, dexp, isObj, round } from '../core/index.js';
34
+ import { MINUTES_PER_DAY } from '../types.js';
35
+ export const PROMPTINGS_DEFAULTS = {
36
+ /** Pull (utility units) from which a pull against a held norm or goal is noticed. */
37
+ noticeAt: 0.15,
38
+ /** Largest turning-away term at full practice (utility units). */
39
+ turnMax: 0.8,
40
+ /** No turning-away term at or above this desperation (same scale as will's survival desperation). */
41
+ survivalDesperation: 0.75,
42
+ /** Practice gained at a prompt where the response held (× (1 - practice)). */
43
+ gainTurned: 0.12,
44
+ /** Practice gained at a prompt where the response was tried but the person assented. */
45
+ gainAttempt: 0.04,
46
+ /** Practice halves over this many days without use. */
47
+ practiceHalfLifeDays: 60,
48
+ /** Guilt intensity at a prompt under the `fault` reading. */
49
+ faultGuilt: 0.15,
50
+ /** Recurrence pull at echo level 1 (utility units); only reached when a host sets `echoRate` > 0. */
51
+ echoScale: 0.5,
52
+ echoHalfLifeDays: 3,
53
+ maxRecent: 64,
54
+ maxResponses: 8,
55
+ maxEcho: 32,
56
+ };
57
+ const READINGS = ['whisper', 'inclination', 'intrusion', 'fault', 'none'];
58
+ const KINDS = ['refuge', 'redirect', 'leave'];
59
+ const halve = (value, minutes, halfLifeDays) => minutes <= 0 ? value : value * dexp((-Math.LN2 * minutes) / (halfLifeDays * MINUTES_PER_DAY));
60
+ /**
61
+ * Turn the slice on, or reconfigure it: recent records and echo are kept, and a response whose id is already held
62
+ * keeps its practice and last use (the spec's `practice` only seeds new responses). Returns the state.
63
+ */
64
+ export function enablePromptings(p, spec = {}) {
65
+ const prev = p.promptings;
66
+ const responses = [];
67
+ for (const r of spec.responses ?? []) {
68
+ if (!isObj(r) || typeof r.id !== 'string' || r.id === '' || !KINDS.includes(r.kind))
69
+ continue;
70
+ if (responses.some((x) => x.id === r.id) || responses.length >= PROMPTINGS_DEFAULTS.maxResponses)
71
+ continue;
72
+ const held = prev?.responses.find((x) => x.id === r.id);
73
+ 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 });
75
+ }
76
+ const state = {
77
+ reading: spec.reading && READINGS.includes(spec.reading) ? spec.reading : 'none',
78
+ responses,
79
+ recent: prev?.recent ?? [],
80
+ echo: prev?.echo ?? {},
81
+ echoRate: Number.isFinite(spec.echoRate) ? clamp01(spec.echoRate) : 0,
82
+ };
83
+ if (spec.readingSource !== undefined)
84
+ state.readingSource = spec.readingSource;
85
+ p.promptings = state;
86
+ return state;
87
+ }
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));
91
+ }
92
+ /** The response a person would use now: highest practice, ties by id. */
93
+ function bestResponse(s, now) {
94
+ let best;
95
+ 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 };
99
+ }
100
+ return best;
101
+ }
102
+ const isPull = (source, tagged) => tagged ||
103
+ source === 'habit' ||
104
+ source === 'promptings:echo' ||
105
+ source.startsWith('emotion:') ||
106
+ source.startsWith('need:');
107
+ /** Whether an option's terms carry a noticed prompt: its strongest pull and the opposing term. Pure. */
108
+ export function promptIn(terms, tags) {
109
+ const tagged = tags?.includes('temptation') ?? false;
110
+ let pull = 0;
111
+ let top;
112
+ let opposed;
113
+ for (const t of terms) {
114
+ if (t.source === 'promptings:turn')
115
+ continue;
116
+ if (t.value > 0 && isPull(t.source, tagged)) {
117
+ pull += t.value;
118
+ if (!top || t.value > top.value)
119
+ top = t;
120
+ }
121
+ else if (t.value < 0 &&
122
+ (t.source.startsWith('norm:') || t.source.startsWith('goal:')) &&
123
+ (!opposed || t.value < opposed.value))
124
+ opposed = t;
125
+ }
126
+ if (!top || !opposed || pull < PROMPTINGS_DEFAULTS.noticeAt)
127
+ return undefined;
128
+ return { source: tagged && !isPull(top.source, false) ? 'host' : top.source, opposedBy: opposed.source };
129
+ }
130
+ /**
131
+ * Extra terms for one option (cognition calls this before summing): the echo pull under `fault`, then the
132
+ * turning-away term when the option carries a prompt. Pure apart from reads; empty without the slice.
133
+ */
134
+ export function promptingTerms(p, action, terms, tags, now, desperation) {
135
+ const s = p.promptings;
136
+ if (!s)
137
+ return [];
138
+ const out = [];
139
+ const e = Object.hasOwn(s.echo, action) ? s.echo[action] : undefined;
140
+ if (e && s.reading === 'fault') {
141
+ const level = halve(e.level, now - e.at, PROMPTINGS_DEFAULTS.echoHalfLifeDays);
142
+ const v = round(PROMPTINGS_DEFAULTS.echoScale * level);
143
+ if (v > 0)
144
+ out.push({ source: 'promptings:echo', value: v });
145
+ }
146
+ if (desperation >= PROMPTINGS_DEFAULTS.survivalDesperation)
147
+ return out;
148
+ const best = bestResponse(s, now);
149
+ if (!best || best.practice <= 0)
150
+ return out;
151
+ if (!promptIn([...terms, ...out], tags))
152
+ return out;
153
+ const v = round(-PROMPTINGS_DEFAULTS.turnMax * best.practice);
154
+ if (v < 0)
155
+ out.push({ source: 'promptings:turn', value: v });
156
+ return out;
157
+ }
158
+ /**
159
+ * Record the prompts of one decision point and learn from them. `chosenId` is the chosen affordance (null when
160
+ * nothing was chosen). The only writer of `p.promptings` after `enablePromptings`.
161
+ */
162
+ export function notePromptings(p, seen, chosenId, now) {
163
+ const s = p.promptings;
164
+ const noted = { records: [], guilt: 0 };
165
+ if (!s || seen.length === 0)
166
+ return noted;
167
+ 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;
171
+ for (const x of seen) {
172
+ const assented = x.affordanceId === chosenId;
173
+ const used = x.turnApplied && best !== undefined;
174
+ const rec = {
175
+ at: now,
176
+ action: x.action,
177
+ affordanceId: x.affordanceId,
178
+ source: x.source,
179
+ opposedBy: x.opposedBy,
180
+ outcome: assented ? 'assented' : used ? 'turned' : 'passed',
181
+ };
182
+ if (used && best) {
183
+ rec.responseId = best.r.id;
184
+ gain = Math.max(gain, assented ? D.gainAttempt : D.gainTurned);
185
+ }
186
+ s.recent.push(rec);
187
+ noted.records.push(rec);
188
+ if (s.reading === 'fault') {
189
+ noted.guilt = Math.max(noted.guilt, D.faultGuilt);
190
+ if (s.echoRate > 0) {
191
+ const prev = Object.hasOwn(s.echo, x.action) ? s.echo[x.action] : undefined;
192
+ const level = prev ? halve(prev.level, now - prev.at, D.echoHalfLifeDays) : 0;
193
+ s.echo[x.action] = { level: round(clamp01(level + s.echoRate * (1 - level))), at: now };
194
+ }
195
+ }
196
+ }
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;
201
+ }
202
+ if (s.recent.length > D.maxRecent)
203
+ s.recent.splice(0, s.recent.length - D.maxRecent);
204
+ const keys = Object.keys(s.echo);
205
+ if (keys.length > D.maxEcho) {
206
+ keys.sort((a, b) => (s.echo[a]?.at ?? 0) - (s.echo[b]?.at ?? 0) || (a < b ? -1 : 1));
207
+ for (const k of keys.slice(0, keys.length - D.maxEcho))
208
+ delete s.echo[k];
209
+ }
210
+ return noted;
211
+ }
212
+ /** Vocabulary for one prompt under a held reading (narration only; it changes nothing). */
213
+ export function promptingLine(reading, outcome, kind) {
214
+ if (outcome === 'assented')
215
+ return reading === 'whisper'
216
+ ? 'a whisper came and was followed'
217
+ : reading === 'inclination'
218
+ ? 'the pull of the self won'
219
+ : 'an urge came and was acted on';
220
+ const how = outcome === 'turned'
221
+ ? kind === 'refuge'
222
+ ? 'sought refuge and '
223
+ : kind === 'redirect'
224
+ ? 'turned to something else and '
225
+ : ''
226
+ : '';
227
+ switch (reading) {
228
+ case 'whisper':
229
+ return `a whisper came; ${how}let it pass`;
230
+ case 'inclination':
231
+ return `the self pulled; ${how}held back`;
232
+ case 'intrusion':
233
+ return `a thought came, not who I am; ${how}let it go`;
234
+ case 'fault':
235
+ return `a thought came and felt like wrongdoing; ${how}let it pass`;
236
+ default:
237
+ return `an urge came; ${how}it passed`;
238
+ }
239
+ }
240
+ export function promptingsReading(p, now = p.now) {
241
+ const s = p.promptings;
242
+ if (!s)
243
+ return undefined;
244
+ const count = (o) => s.recent.filter((r) => r.outcome === o).length;
245
+ const kindOf = (id) => s.responses.find((r) => r.id === id)?.kind;
246
+ const view = {
247
+ reading: s.reading,
248
+ prompts: s.recent.length,
249
+ turned: count('turned'),
250
+ passed: count('passed'),
251
+ assented: count('assented'),
252
+ responses: s.responses.map((r) => ({ id: r.id, kind: r.kind, practice: round(practiceAt(r, now)) })),
253
+ lines: s.recent.slice(-5).map((r) => promptingLine(s.reading, r.outcome, kindOf(r.responseId))),
254
+ };
255
+ if (s.readingSource !== undefined)
256
+ view.readingSource = s.readingSource;
257
+ return view;
258
+ }
259
+ export function nafsPatterns(p, inputs) {
260
+ const recent = p.promptings?.recent ?? [];
261
+ const assentedAgainstNorm = recent.filter((r) => r.outcome === 'assented' && r.opposedBy.startsWith('norm:'));
262
+ const letPass = recent.filter((r) => r.outcome !== 'assented').length;
263
+ const reproach = inputs.guiltLoad > 0 || inputs.guilt > 0.05;
264
+ return [
265
+ {
266
+ name: 'urging',
267
+ ref: 'Qur’an 12:53',
268
+ evident: assentedAgainstNorm.length > 0,
269
+ evidence: `${assentedAgainstNorm.length} recent prompts followed against a held norm`,
270
+ },
271
+ {
272
+ name: 'reproaching',
273
+ ref: 'Qur’an 75:2',
274
+ evident: reproach,
275
+ evidence: `open-breach weight ${round(inputs.guiltLoad, 2)}, felt guilt ${round(inputs.guilt, 2)}`,
276
+ },
277
+ {
278
+ name: 'at rest',
279
+ ref: 'Qur’an 89:27',
280
+ evident: letPass > 0 && assentedAgainstNorm.length === 0 && !reproach,
281
+ evidence: `${letPass} recent prompts let pass, none followed against a norm, no felt reproach`,
282
+ },
283
+ ];
284
+ }
285
+ const unit = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;
286
+ const minute = (x) => typeof x === 'number' && Number.isFinite(x);
287
+ const str = (x) => typeof x === 'string' && x !== '' && x.length <= 200;
288
+ /** Validate a saved slice; a malformed slice is dropped (undefined), malformed entries are skipped. */
289
+ export function sanitizePromptings(input, now) {
290
+ if (!isObj(input) || !Array.isArray(input.responses) || !Array.isArray(input.recent) || !isObj(input.echo))
291
+ return undefined;
292
+ if (typeof input.reading !== 'string' || !READINGS.includes(input.reading))
293
+ return undefined;
294
+ const D = PROMPTINGS_DEFAULTS;
295
+ const responses = [];
296
+ for (const r of input.responses.slice(0, D.maxResponses)) {
297
+ if (!isObj(r) || !str(r.id) || !KINDS.includes(r.kind) || !unit(r.practice))
298
+ continue;
299
+ if (!minute(r.lastUsedAt) || r.lastUsedAt > now || responses.some((x) => x.id === r.id))
300
+ continue;
301
+ responses.push({
302
+ id: r.id,
303
+ kind: r.kind,
304
+ practice: r.practice,
305
+ lastUsedAt: r.lastUsedAt,
306
+ });
307
+ }
308
+ const recent = [];
309
+ for (const r of input.recent.slice(-D.maxRecent)) {
310
+ if (!isObj(r) || !minute(r.at) || r.at > now || !str(r.action) || !str(r.affordanceId))
311
+ continue;
312
+ if (!str(r.source) || !str(r.opposedBy))
313
+ continue;
314
+ if (r.outcome !== 'passed' && r.outcome !== 'turned' && r.outcome !== 'assented')
315
+ continue;
316
+ const rec = {
317
+ at: r.at,
318
+ action: r.action,
319
+ affordanceId: r.affordanceId,
320
+ source: r.source,
321
+ opposedBy: r.opposedBy,
322
+ outcome: r.outcome,
323
+ };
324
+ if (str(r.responseId))
325
+ rec.responseId = r.responseId;
326
+ recent.push(rec);
327
+ }
328
+ const echo = {};
329
+ for (const k of Object.keys(input.echo).slice(0, D.maxEcho)) {
330
+ if (k === '__proto__' || !str(k))
331
+ continue;
332
+ const e = input.echo[k];
333
+ if (!isObj(e) || !unit(e.level) || !minute(e.at) || e.at > now)
334
+ continue;
335
+ echo[k] = { level: e.level, at: e.at };
336
+ }
337
+ const rate = input.echoRate;
338
+ const out = {
339
+ reading: input.reading,
340
+ responses,
341
+ recent,
342
+ echo,
343
+ echoRate: typeof rate === 'number' && Number.isFinite(rate) && rate >= 0 ? Math.min(1, rate) : 0,
344
+ };
345
+ if (str(input.readingSource))
346
+ out.readingSource = input.readingSource;
347
+ return out;
348
+ }
@@ -0,0 +1 @@
1
+ export * from './prospection.ts';
@@ -0,0 +1 @@
1
+ export * from './prospection.js';
@@ -0,0 +1,23 @@
1
+ import type { Affordance, ConsequenceRevision, Minute, Person, RetainedConsequence } from '../types.ts';
2
+ export declare const PROSPECTION_DEFAULTS: {
3
+ maxEntries: number;
4
+ maxRevisions: number;
5
+ maxBatch: number;
6
+ maxIdentifierLength: number;
7
+ horizon: number;
8
+ maxMaterialCost: number;
9
+ };
10
+ export interface ConsequenceEvidence {
11
+ entry: RetainedConsequence;
12
+ matches: boolean;
13
+ feasible: boolean;
14
+ successorStartsAt: Minute;
15
+ successorEndsAt: Minute;
16
+ accessProbability: number;
17
+ terminalProbability: number;
18
+ }
19
+ /** Read current retained knowledge and feasibility. Does not mutate, prune or consume RNG. */
20
+ export declare function consequenceEvidence(p: Person, aff: Affordance, now?: Minute): {
21
+ entries: ConsequenceEvidence[];
22
+ revisions: ConsequenceRevision[];
23
+ };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * SCOPE: one received current-action → successor consequence, acquired only through
3
+ * attended external testimony for this person or an untargeted broadcast. Knowledge is person-owned, source-labelled, bounded and saved.
4
+ * Temporal goals value expected completion using time, success and source reliability.
5
+ * One coherent route may contribute; it never executes actions, grants consent or
6
+ * advances a goal. Own completed triggers consume their received opportunity.
7
+ * This is not graph search, inferred procedure learning or a model of future bodies,
8
+ * waiting opportunity cost or another person's agency. Parameters are engineering
9
+ * bounds, not fitted psychological estimates. Revision memory is bounded, not an audit.
10
+ */
11
+ import { temporalGoalValue } from '../agenda/agenda.js';
12
+ import { trustOf } from '../beliefs/index.js';
13
+ import { cmpStr, isNum, isObj, round } from '../core/index.js';
14
+ import { expectedEffect } from '../memory/index.js';
15
+ import { MAX_MINUTE, MINUTES_PER_DAY } from '../types.js';
16
+ export const PROSPECTION_DEFAULTS = {
17
+ maxEntries: 16,
18
+ maxRevisions: 64,
19
+ maxBatch: 4,
20
+ maxIdentifierLength: 96,
21
+ horizon: 7 * MINUTES_PER_DAY,
22
+ maxMaterialCost: 1_000_000,
23
+ };
24
+ const id = (x) => typeof x === 'string' &&
25
+ x.length > 0 &&
26
+ x.length <= PROSPECTION_DEFAULTS.maxIdentifierLength &&
27
+ x !== '__proto__' &&
28
+ x !== 'constructor' &&
29
+ x !== 'prototype';
30
+ const minute = (x) => isNum(x) && Math.abs(x) <= MAX_MINUTE;
31
+ const unit = (x) => isNum(x) && x >= 0 && x <= 1;
32
+ const revision = (x) => typeof x === 'number' && Number.isSafeInteger(x) && x >= 0;
33
+ const identity = (a, b) => a.sourceId === b.sourceId && a.id === b.id;
34
+ const order = (a, b) => a.receivedAt - b.receivedAt || cmpStr(a.sourceId, b.sourceId) || cmpStr(a.id, b.id);
35
+ function action(x) {
36
+ if (!isObj(x) ||
37
+ !id(x.action) ||
38
+ (x.targetId !== undefined && !id(x.targetId)) ||
39
+ (x.placeId !== undefined && !id(x.placeId)))
40
+ return undefined;
41
+ return {
42
+ action: x.action,
43
+ ...(x.targetId !== undefined ? { targetId: x.targetId } : {}),
44
+ ...(x.placeId !== undefined ? { placeId: x.placeId } : {}),
45
+ };
46
+ }
47
+ function consequence(x, receivedAt) {
48
+ if (!isObj(x) || !id(x.id) || !revision(x.revision))
49
+ return undefined;
50
+ const trigger = action(x.trigger);
51
+ const successorAction = action(x.successor);
52
+ const s = x.successor;
53
+ if (!trigger ||
54
+ !successorAction ||
55
+ !isObj(s) ||
56
+ !isNum(s.duration) ||
57
+ s.duration < 0 ||
58
+ s.duration > PROSPECTION_DEFAULTS.horizon ||
59
+ !unit(s.effort) ||
60
+ (s.focus !== undefined && !unit(s.focus)) ||
61
+ (s.materialCost !== undefined &&
62
+ (!isNum(s.materialCost) || s.materialCost < 0 || s.materialCost > PROSPECTION_DEFAULTS.maxMaterialCost)))
63
+ return undefined;
64
+ if (!minute(x.availableFrom) ||
65
+ !minute(x.availableUntil) ||
66
+ !minute(x.expiresAt) ||
67
+ x.availableFrom > x.availableUntil ||
68
+ x.availableUntil > x.expiresAt ||
69
+ x.expiresAt < receivedAt ||
70
+ x.expiresAt > receivedAt + PROSPECTION_DEFAULTS.horizon ||
71
+ !unit(x.chance) ||
72
+ !unit(x.confidence))
73
+ return undefined;
74
+ return {
75
+ id: x.id,
76
+ revision: x.revision,
77
+ trigger,
78
+ successor: {
79
+ ...successorAction,
80
+ duration: s.duration,
81
+ effort: s.effort,
82
+ ...(s.focus !== undefined ? { focus: s.focus } : {}),
83
+ ...(s.materialCost !== undefined ? { materialCost: s.materialCost } : {}),
84
+ },
85
+ availableFrom: x.availableFrom,
86
+ availableUntil: x.availableUntil,
87
+ expiresAt: x.expiresAt,
88
+ chance: x.chance,
89
+ confidence: x.confidence,
90
+ };
91
+ }
92
+ function retained(x, now) {
93
+ if (!isObj(x) || !id(x.sourceId) || x.channel !== 'told' || !minute(x.receivedAt) || x.receivedAt > now)
94
+ return undefined;
95
+ const c = consequence(x, x.receivedAt);
96
+ if (!c ||
97
+ (x.triggeredAt !== undefined &&
98
+ (!minute(x.triggeredAt) || x.triggeredAt < x.receivedAt || x.triggeredAt > now)))
99
+ return undefined;
100
+ return {
101
+ ...c,
102
+ sourceId: x.sourceId,
103
+ channel: 'told',
104
+ receivedAt: x.receivedAt,
105
+ ...(x.triggeredAt !== undefined ? { triggeredAt: x.triggeredAt } : {}),
106
+ };
107
+ }
108
+ function cleaned(state, now) {
109
+ const revisions = state.revisions.filter((r) => now - r.receivedAt <= PROSPECTION_DEFAULTS.horizon);
110
+ return {
111
+ entries: state.entries.filter((e) => e.expiresAt >= now && revisions.some((r) => identity(r, e) && r.revision === e.revision)),
112
+ revisions,
113
+ };
114
+ }
115
+ function store(p, s) {
116
+ if (s.entries.length || s.revisions.length)
117
+ p.prospection = s;
118
+ else
119
+ delete p.prospection;
120
+ }
121
+ /** Expiry changes only existing optional state. @internal */
122
+ export function pruneConsequences(p, now = p.now) {
123
+ if (p.prospection)
124
+ store(p, cleaned(p.prospection, now));
125
+ }
126
+ /** Called only for an attended percept. Invalid combined batches mutate no prospection state. @internal */
127
+ export function receiveConsequences(p, pc) {
128
+ if (pc.consequences === undefined && pc.withdrawConsequences === undefined)
129
+ return false;
130
+ if (pc.channel !== 'told' ||
131
+ !id(pc.actorId) ||
132
+ pc.actorId === p.id ||
133
+ (pc.targetId !== undefined && pc.targetId !== p.id))
134
+ return false;
135
+ const adds = pc.consequences ?? [];
136
+ const removes = pc.withdrawConsequences ?? [];
137
+ if (!Array.isArray(adds) ||
138
+ !Array.isArray(removes) ||
139
+ adds.length + removes.length > PROSPECTION_DEFAULTS.maxBatch ||
140
+ adds.length + removes.length === 0)
141
+ return false;
142
+ const additions = adds.map((x) => consequence(x, p.now));
143
+ if (additions.some((x) => x === undefined) ||
144
+ removes.some((x) => !isObj(x) || !id(x.id) || !revision(x.revision)))
145
+ return false;
146
+ const withdrawals = removes;
147
+ const batch = [...additions.map((x) => ({ id: x?.id, revision: x?.revision })), ...withdrawals];
148
+ if (new Set(batch.map((x) => x.id)).size !== batch.length)
149
+ return false;
150
+ const state = cleaned(structuredClone(p.prospection ?? { entries: [], revisions: [] }), p.now);
151
+ const sourceId = pc.actorId;
152
+ const updates = batch.filter((b) => !state.revisions.some((r) => r.sourceId === sourceId && r.id === b.id && r.revision >= (b.revision ?? 0)));
153
+ const newIds = updates.filter((b) => !state.revisions.some((r) => r.sourceId === sourceId && r.id === b.id));
154
+ // Never evict a still-valid tombstone to admit a batch; dedup holds within the stated horizon.
155
+ if (state.revisions.length + newIds.length > PROSPECTION_DEFAULTS.maxRevisions)
156
+ return false;
157
+ for (const b of updates) {
158
+ state.entries = state.entries.filter((e) => !(e.sourceId === sourceId && e.id === b.id));
159
+ state.revisions = state.revisions.filter((r) => !(r.sourceId === sourceId && r.id === b.id));
160
+ const addition = additions.find((a) => a?.id === b.id);
161
+ if (addition)
162
+ state.entries.push({ ...addition, sourceId, channel: 'told', receivedAt: p.now });
163
+ state.revisions.push({ sourceId, id: b.id, revision: b.revision, receivedAt: p.now });
164
+ }
165
+ if (state.entries.length > PROSPECTION_DEFAULTS.maxEntries) {
166
+ const oldest = [...state.entries]
167
+ .sort((a, b) => Number(b.triggeredAt !== undefined) - Number(a.triggeredAt !== undefined) || order(a, b))
168
+ .slice(0, state.entries.length - PROSPECTION_DEFAULTS.maxEntries);
169
+ state.entries = state.entries.filter((e) => !oldest.includes(e));
170
+ }
171
+ store(p, state);
172
+ return true;
173
+ }
174
+ const matches = (a, b) => a.action === b.action && a.targetId === b.targetId && a.placeId === b.placeId;
175
+ /** Own actual completed trigger consumes an opportunity, without predicting consent. @internal */
176
+ export function consumeConsequences(p, completed) {
177
+ for (const e of p.prospection?.entries ?? [])
178
+ if (e.triggeredAt === undefined && matches(e.trigger, completed))
179
+ e.triggeredAt = p.now;
180
+ }
181
+ /** Read current retained knowledge and feasibility. Does not mutate, prune or consume RNG. */
182
+ export function consequenceEvidence(p, aff, now = p.now) {
183
+ return {
184
+ entries: (p.prospection?.entries ?? []).map((entry) => {
185
+ const start = Math.max(now + Math.max(0, aff.duration), entry.availableFrom);
186
+ const end = start + entry.successor.duration;
187
+ const match = matches(entry.trigger, aff);
188
+ const feasible = match &&
189
+ entry.triggeredAt === undefined &&
190
+ now <= entry.expiresAt &&
191
+ end <= entry.availableUntil &&
192
+ Number.isFinite(end);
193
+ const accessProbability = expectedEffect(p, aff).successRate * entry.chance * entry.confidence * trustOf(p, entry.sourceId);
194
+ return {
195
+ entry: structuredClone(entry),
196
+ matches: match,
197
+ feasible,
198
+ successorStartsAt: start,
199
+ successorEndsAt: end,
200
+ accessProbability,
201
+ terminalProbability: accessProbability * expectedEffect(p, successorOffer(entry)).successRate,
202
+ };
203
+ }),
204
+ revisions: structuredClone((p.prospection?.revisions ?? []).filter((r) => now - r.receivedAt <= PROSPECTION_DEFAULTS.horizon)),
205
+ };
206
+ }
207
+ function successorOffer(e) {
208
+ const { materialCost, ...rest } = e.successor;
209
+ return {
210
+ ...rest,
211
+ id: 'prospective-successor',
212
+ label: 'Received successor',
213
+ advertises: {},
214
+ ...(materialCost !== undefined ? { material: -materialCost } : {}),
215
+ };
216
+ }
217
+ /** Select a whole route in the exact rounded units cognition emits. @internal */
218
+ export function receivedGoalRoute(p, aff, now, maturityScale, costs) {
219
+ const goals = p.agenda.goals.filter((g) => g.status === 'active' && g.temporal !== undefined);
220
+ if (!goals.length || !p.prospection?.entries.length)
221
+ return undefined;
222
+ const direct = goals.map((g) => temporalGoalValue(p, g, aff, now, now + Math.max(0, aff.duration)));
223
+ const baseline = direct.reduce((sum, value) => sum + round(value * maturityScale), 0);
224
+ let bestScore = baseline;
225
+ let best;
226
+ const entries = consequenceEvidence(p, aff, now).entries.sort((a, b) => cmpStr(a.entry.sourceId, b.entry.sourceId) || cmpStr(a.entry.id, b.entry.id));
227
+ for (const e of entries) {
228
+ if (!e.feasible)
229
+ continue;
230
+ const successor = successorOffer(e.entry);
231
+ const values = goals.map((g, i) => Math.max(direct[i] ?? 0, e.accessProbability * temporalGoalValue(p, g, successor, now, e.successorEndsAt)));
232
+ const cost = round(e.accessProbability * Math.min(0, costs(successor)));
233
+ const total = values.reduce((sum, value) => sum + round(value * maturityScale), 0) + cost;
234
+ if (!(round(total - bestScore) > 0))
235
+ continue;
236
+ bestScore = total;
237
+ best = {
238
+ goals: new Map(goals.map((g, i) => [g.id, values[i] ?? 0])),
239
+ cost: { source: `prospection:cost:${e.entry.sourceId}:${e.entry.id}`, value: cost },
240
+ };
241
+ }
242
+ return best;
243
+ }
244
+ /** Conservative optional-slice restore; ambiguous identities drop the slice. @internal */
245
+ export function sanitizeProspection(input, now, personId) {
246
+ if (!isObj(input) ||
247
+ !Array.isArray(input.entries) ||
248
+ !Array.isArray(input.revisions) ||
249
+ input.entries.length > PROSPECTION_DEFAULTS.maxEntries ||
250
+ input.revisions.length > PROSPECTION_DEFAULTS.maxRevisions)
251
+ return undefined;
252
+ const entries = input.entries.map((x) => retained(x, now)).filter((e) => e?.sourceId !== personId);
253
+ const revisions = [];
254
+ for (const r of input.revisions) {
255
+ if (!isObj(r) ||
256
+ !id(r.sourceId) ||
257
+ r.sourceId === personId ||
258
+ !id(r.id) ||
259
+ !revision(r.revision) ||
260
+ !minute(r.receivedAt) ||
261
+ r.receivedAt > now)
262
+ continue;
263
+ if (revisions.some((old) => identity(old, r)))
264
+ return undefined;
265
+ revisions.push({ sourceId: r.sourceId, id: r.id, revision: r.revision, receivedAt: r.receivedAt });
266
+ }
267
+ const valid = [];
268
+ for (const e of entries) {
269
+ if (!e)
270
+ continue;
271
+ if (valid.some((old) => identity(old, e)))
272
+ return undefined;
273
+ if (revisions.some((r) => identity(r, e) && r.revision === e.revision && r.receivedAt === e.receivedAt))
274
+ valid.push(e);
275
+ }
276
+ const state = cleaned({ entries: valid, revisions }, now);
277
+ return state.entries.length || state.revisions.length ? state : undefined;
278
+ }
package/dist/restore.js CHANGED
@@ -20,6 +20,8 @@ import { sanitizeMethods } from './methods/index.js';
20
20
  import { migrate } from './migrate.js';
21
21
  import { sanitizeBonds } from './partnering/index.js';
22
22
  import { cleanRetention, createPerson, traceLimit } from './person.js';
23
+ import { sanitizePromptings } from './promptings/index.js';
24
+ import { sanitizeProspection } from './prospection/index.js';
23
25
  import { sanitizeSkillRetention } from './skills/index.js';
24
26
  import { sanitizeGroups, sanitizeImpressions } from './social/index.js';
25
27
  import { ENGINE_VERSION, MAX_MINUTE, PERSON_SCHEMA } from './types.js';
@@ -59,6 +61,8 @@ const OPTIONAL_KEYS = new Set([
59
61
  'ambient',
60
62
  'retention',
61
63
  'methods',
64
+ 'prospection',
65
+ 'promptings',
62
66
  ]);
63
67
  /** Kind of a JSON value for the per-field type check in `restore`. */
64
68
  const kindOf = (x) => (Array.isArray(x) ? 'array' : x === null ? 'null' : typeof x);
@@ -171,6 +175,8 @@ export function restore(input) {
171
175
  keepValid(out, 'ambient', sanitizeAmbient);
172
176
  keepValid(out, 'retention', cleanRetention);
173
177
  keepValid(out, 'methods', sanitizeMethods);
178
+ keepValid(out, 'prospection', (x) => sanitizeProspection(x, out.now, out.id));
179
+ keepValid(out, 'promptings', (x) => sanitizePromptings(x, out.now));
174
180
  boundLists(out);
175
181
  return out;
176
182
  }