@adam0white/human-framework 2.3.0 → 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.
package/README.md CHANGED
@@ -348,7 +348,7 @@ Import these functions from the package. Rules/evidence live in optional `Person
348
348
 
349
349
  ## Receiving a possible future route
350
350
 
351
- The pending 2.3.0 increment lets an attended explanation connect one current action to one possible successor. The host supplies the explanation the person received, not a private success bonus.
351
+ HF 2.3.0 lets an attended explanation connect one current action to one possible successor. The host supplies the explanation the person received, not a private success bonus.
352
352
 
353
353
  ```ts
354
354
  import {
@@ -1,4 +1,5 @@
1
1
  import { type HabitContext } from '../habits/index.ts';
2
+ import { type PromptingSeen } from '../promptings/index.ts';
2
3
  import type { Affordance, BodyReadout, Command, Considered, DecisionRecord, LifeModifiers, Minute, NeedReading, Person, Suggestion, Term, Unit } from '../types.ts';
3
4
  import { type CommandOutcome, type WillContext } from '../will/index.ts';
4
5
  export declare const COGNITION_DEFAULTS: {
@@ -132,6 +133,8 @@ export interface Decision {
132
133
  needDeltas: Partial<Record<'autonomy', number>>;
133
134
  /** How a command in force fared (absent when none). */
134
135
  command?: CommandOutcome;
136
+ /** Prompts noticed at this decision point (only with `p.promptings`); the composite records them. */
137
+ prompts?: PromptingSeen[];
135
138
  }
136
139
  /** Highest physiological urgency: the input to the necessity exception. */
137
140
  export declare function desperationOf(needs: NeedReading[]): number;
@@ -34,6 +34,7 @@ import { expectedEffect } from '../memory/index.js';
34
34
  import { methodEvidence } from '../methods/index.js';
35
35
  import { urgencyOf } from '../needs/index.js';
36
36
  import { partneringTerms } from '../partnering/index.js';
37
+ import { promptIn, promptingTerms } from '../promptings/index.js';
37
38
  import { receivedGoalRoute } from '../prospection/index.js';
38
39
  import { companionSteadiness, socialTerms } from '../social/index.js';
39
40
  import { PHYSIOLOGICAL_NEEDS, VALUE_KEYS } from '../types.js';
@@ -398,6 +399,10 @@ export function consider(p, aff, ctx) {
398
399
  if (!inBreak(p))
399
400
  for (const t of rememberedTerms(p, aff, ctx.now, voices))
400
401
  push(t.source, t.value);
402
+ // Promptings (2.4.0, opt-in): a recurrence pull under `fault` and a practised turning-away from a noticed prompt.
403
+ if (p.promptings)
404
+ for (const t of promptingTerms(p, aff.action, terms, aff.tags, ctx.now, ctx.desperation))
405
+ push(t.source, t.value);
401
406
  let utility = 0;
402
407
  for (const t of terms)
403
408
  utility += t.value;
@@ -471,5 +476,22 @@ export function decide(p, affordances, ctx) {
471
476
  const out = { record, needDeltas };
472
477
  if (res.command)
473
478
  out.command = res.command;
479
+ if (p.promptings) {
480
+ const prompts = [];
481
+ const tagsOf = new Map(sorted.map((a) => [a.id, a.tags]));
482
+ for (const c of res.considered) {
483
+ const seen = promptIn(c.terms, tagsOf.get(c.affordanceId));
484
+ if (seen)
485
+ prompts.push({
486
+ affordanceId: c.affordanceId,
487
+ action: c.action,
488
+ ...seen,
489
+ // A vetoed option was not turned from: the will blocked it, so the response earns no practice.
490
+ turnApplied: c.vetoed === undefined && c.terms.some((t) => t.source === 'promptings:turn'),
491
+ });
492
+ }
493
+ if (prompts.length > 0)
494
+ out.prompts = prompts;
495
+ }
474
496
  return out;
475
497
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.3.0";
2
+ export declare const FRAMEWORK_VERSION = "2.4.0";
3
3
  export type { CrisisEvent, PracticeKind } from './affect/index.ts';
4
4
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.ts';
5
5
  export type { GoalTemplate, PrayerCalendar, PrayerSchedule, PrayerTimes, Retimer, } from './agenda/index.ts';
@@ -28,6 +28,7 @@ export * from './narrate/index.ts';
28
28
  export * from './needs/index.ts';
29
29
  export * from './partnering/index.ts';
30
30
  export * from './person.ts';
31
+ export * from './promptings/index.ts';
31
32
  export type { ConsequenceEvidence } from './prospection/index.ts';
32
33
  export { consequenceEvidence, PROSPECTION_DEFAULTS } from './prospection/index.ts';
33
34
  export * from './restore.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export const FRAMEWORK_VERSION = '2.3.0';
2
+ export const FRAMEWORK_VERSION = '2.4.0';
3
3
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.js';
4
4
  // `agenda.release` collides with `affect.release`; the agenda one is re-exported under a clearer name.
5
5
  export { AGENDA_DEFAULTS, abandonGoal, adoptGoal, advanceAgenda, agendaTerms, applyExemptions, CARE_DEFAULTS, calendarRetimer, careDuty, commitmentPressure, createAgenda, DEFAULT_PRAYER_TIMES, EID_PRAYER_IMPORTANCE, EXEMPTION_DEFAULTS, eidPrayer, eidWindow, fastWindow, iftarWindow, inMakruhTime, isUnderWay, MAKRUH_DEFAULTS, makruhWindows, missedExcuse, onFinished, owedMakeUps, PRAYER_IMPORTANCE, PURPOSE_DEFAULTS, prayerWindows, pressureReachedAt, promise, proposeGoals, RAMADAN_DEFAULTS, ramadanFast, ramadanMeals, release as releaseCommitment, retimeCommitments, revisePurposes, scheduleMakeUp, spanMeetsWindow, suhoorWindow, timesFor, violatesAbstention, } from './agenda/index.js';
@@ -25,6 +25,7 @@ export * from './narrate/index.js';
25
25
  export * from './needs/index.js';
26
26
  export * from './partnering/index.js';
27
27
  export * from './person.js';
28
+ export * from './promptings/index.js';
28
29
  export { consequenceEvidence, PROSPECTION_DEFAULTS } from './prospection/index.js';
29
30
  export * from './restore.js';
30
31
  export * from './scenarios/index.js';
package/dist/migrate.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * field of the optional surroundings slice (`ambient.now` → `ambient.percept`) and changes no behaviour (the optional `social.impressions` and `social.reserve`, like `retention`, are optional and start
9
9
  * absent in a migrated save); `restore` then fills and sanitizes
10
10
  * the slices as for any save. 2.0.0 to 2.1.0 adds optional conditional method state, absent
11
- * until explicitly used, so existing hosts retain their previous behavior; that step stamps the version. 2.1.0 to 2.2.0 preserves optional method records and stamps the version; successful alternatives now retain independent support. 2.2.0 to 2.3.0 stamps the version; received consequences and temporal goals remain absent unless explicitly supplied. A migrated save continues under the current engine's rules: it restores and runs,
11
+ * until explicitly used, so existing hosts retain their previous behavior; that step stamps the version. 2.1.0 to 2.2.0 preserves optional method records and stamps the version; successful alternatives now retain independent support. 2.2.0 to 2.3.0 stamps the version; received consequences and temporal goals remain absent unless explicitly supplied. 2.3.0 to 2.4.0 stamps the version; promptings remain absent unless enabled. A migrated save continues under the current engine's rules: it restores and runs,
12
12
  * but it does not reproduce what the old engine would have done next where the rules changed (1.4.0 to 1.5.0
13
13
  * changed how standing advice is heard; 1.8.0 to 1.9.0 changed the omission rule). Saves older than 1.4.0 and unknown versions are refused with an error.
14
14
  *
@@ -58,6 +58,11 @@ export const MIGRATIONS = {
58
58
  note: 'the surroundings field `ambient.now` is renamed `ambient.percept`; per-person retention is optional and starts absent; behaviour unchanged',
59
59
  apply: renameAmbient,
60
60
  },
61
+ '2.3.0': {
62
+ to: '2.4.0',
63
+ note: 'promptings are optional and start absent; existing hosts retain exact behavior',
64
+ apply: stamp,
65
+ },
61
66
  '2.2.0': {
62
67
  to: '2.3.0',
63
68
  note: 'received consequences and temporal goals are optional; existing hosts retain exact behavior',
package/dist/person.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type NafsPattern } from './promptings/index.ts';
1
2
  import { type SkillTransfer } from './skills/index.ts';
2
3
  import type { Activity, Affordance, BodyLoad, BodyReadout, Command, Commitment, DecisionRecord, LearningDomain, Minute, NeedId, NeedReading, NormDefinition, Outcome, Percept, Person, PersonSpec, Retention, Suggestion, SuggestionResolution, Unit } from './types.ts';
3
4
  import { type CommandOutcome } from './will/index.ts';
@@ -230,3 +231,8 @@ export declare function finish(p: Person, outcome: Outcome, opts?: FinishOptions
230
231
  * entries are ignored; an empty or wholly invalid retention restores the defaults. Changes no decision.
231
232
  */
232
233
  export declare function setRetention(p: Person, retention: Retention): void;
234
+ /**
235
+ * The nafs lens (2.4.0, read-only; see `promptings/` SCOPE): the three Qur'anic descriptions of one self (12:53,
236
+ * 75:2, 89:27), each with the matching evidence in existing state. Descriptions, never a level or a rank.
237
+ */
238
+ export declare function nafsReading(p: Person): NafsPattern[];
package/dist/person.js CHANGED
@@ -26,7 +26,7 @@ import { advanceBody, BODY_DEFAULTS, checkDowned, clearDowned, consume, createBo
26
26
  import { ageCharacter, enableCharacterChange, noteCharacterDay, noteCharacterSocial, } from './character/index.js';
27
27
  import { closeDay, enableYearbook, endDay, noteAnswer, noteCommitments, noteDecision, noteMood, noteOutcome, trimChronicle, } from './chronicle/index.js';
28
28
  import { decide as cognitionDecide, desperationOf, scoreAll } from './cognition/index.js';
29
- import { createConscience, heldNorms, normVeto, recordDeed, recordRepair, repent, } from './conscience/index.js';
29
+ import { createConscience, guiltLoad, heldNorms, normVeto, recordDeed, recordRepair, repent, } from './conscience/index.js';
30
30
  import { clamp01, clampSigned, createRng, dayOf, isObj, minuteOfDay } from './core/index.js';
31
31
  import { ambientBodyParams, ambientModifiers, ambientMood, ambientNeeds } from './environment/index.js';
32
32
  import { aptitudeOf, createFamily, pregnancyModifiers } from './family/index.js';
@@ -36,6 +36,7 @@ import { advanceMemory, createMemory, enableGists, foldGists, learnOutcome, reca
36
36
  import { intentionFor, narrateDecision, voiceLine } from './narrate/index.js';
37
37
  import { advanceNeeds, createNeeds, levelOf, meanSatisfaction, readNeeds, satisfy, urgencyOf, } from './needs/index.js';
38
38
  import { widow } from './partnering/index.js';
39
+ import { enablePromptings, nafsPatterns, notePromptings } from './promptings/index.js';
39
40
  import { consumeConsequences, pruneConsequences, receiveConsequences } from './prospection/index.js';
40
41
  import { enableSkillRetention, learnByWatching, practise, seedSkills, } from './skills/index.js';
41
42
  import { advanceSocial, applyReputationBelief, careFor, closeness, judge, markDeceased, relationshipWith, seedRelationships, socialEvent, threatAppraisal, } from './social/index.js';
@@ -153,6 +154,8 @@ export function createPerson(spec) {
153
154
  enableCharacterChange(p);
154
155
  if (on?.skillRetention)
155
156
  enableSkillRetention(p);
157
+ if (on?.promptings)
158
+ enablePromptings(p, on.promptings);
156
159
  return p;
157
160
  }
158
161
  /**
@@ -817,7 +820,7 @@ export function decide(p, affordances, opts = {}) {
817
820
  const review = p.activity !== null;
818
821
  if (review)
819
822
  ctx.quiet = true;
820
- const { record, needDeltas, command: co } = cognitionDecide(p, list, ctx);
823
+ const { record, needDeltas, command: co, prompts } = cognitionDecide(p, list, ctx);
821
824
  if (review)
822
825
  record.review = true;
823
826
  const interruptReason = opts.interrupt ?? p.activity?.interrupt?.reason;
@@ -831,6 +834,12 @@ export function decide(p, affordances, opts = {}) {
831
834
  record.narration = narrateDecision(p, record);
832
835
  const resolutions = resolutionsOf(record);
833
836
  const continuing = review && (record.chosenAffordanceId === null || record.chosenAffordanceId === p.activity?.affordanceId);
837
+ // Promptings (opt-in): a review that keeps the running activity is not a new decision point for a prompt.
838
+ if (prompts && !continuing) {
839
+ const noted = notePromptings(p, prompts, record.chosenAffordanceId, p.now);
840
+ if (noted.guilt > 0)
841
+ feel(p, 'guilt', noted.guilt, 'prompting', p.now);
842
+ }
834
843
  for (const res of resolutions) {
835
844
  res.says = voiceLine(p, res, record.id);
836
845
  if (res.verdict === 'complied' && !continuing) {
@@ -1390,3 +1399,14 @@ export function setRetention(p, retention) {
1390
1399
  p.trace.splice(0, p.trace.length - keep);
1391
1400
  trimChronicle(p);
1392
1401
  }
1402
+ /**
1403
+ * The nafs lens (2.4.0, read-only; see `promptings/` SCOPE): the three Qur'anic descriptions of one self (12:53,
1404
+ * 75:2, 89:27), each with the matching evidence in existing state. Descriptions, never a level or a rank.
1405
+ */
1406
+ export function nafsReading(p) {
1407
+ let guilt = 0;
1408
+ for (const e of p.affect.emotions)
1409
+ if (e.id === 'guilt' && e.cause !== 'prompting')
1410
+ guilt = Math.max(guilt, e.intensity);
1411
+ return nafsPatterns(p, { guiltLoad: guiltLoad(p), guilt });
1412
+ }
@@ -0,0 +1 @@
1
+ export * from './promptings.ts';
@@ -0,0 +1 @@
1
+ export * from './promptings.js';
@@ -0,0 +1,100 @@
1
+ import type { Minute, Person, PromptingReading, PromptingRecord, PromptingResponse, PromptingResponseKind, PromptingsSpec, PromptingsState, Term, Unit } from '../types.ts';
2
+ export declare const PROMPTINGS_DEFAULTS: {
3
+ /** Pull (utility units) from which a pull against a held norm or goal is noticed. */
4
+ readonly noticeAt: 0.15;
5
+ /** Largest turning-away term at full practice (utility units). */
6
+ readonly turnMax: 0.8;
7
+ /** No turning-away term at or above this desperation (same scale as will's survival desperation). */
8
+ readonly survivalDesperation: 0.75;
9
+ /** Practice gained at a prompt where the response held (× (1 - practice)). */
10
+ readonly gainTurned: 0.12;
11
+ /** Practice gained at a prompt where the response was tried but the person assented. */
12
+ readonly gainAttempt: 0.04;
13
+ /** Practice halves over this many days without use. */
14
+ readonly practiceHalfLifeDays: 60;
15
+ /** Guilt intensity at a prompt under the `fault` reading. */
16
+ readonly faultGuilt: 0.15;
17
+ /** Recurrence pull at echo level 1 (utility units); only reached when a host sets `echoRate` > 0. */
18
+ readonly echoScale: 0.5;
19
+ readonly echoHalfLifeDays: 3;
20
+ readonly maxRecent: 64;
21
+ readonly maxResponses: 8;
22
+ readonly maxEcho: 32;
23
+ };
24
+ /**
25
+ * Turn the slice on, or reconfigure it: recent records and echo are kept, and a response whose id is already held
26
+ * keeps its practice and last use (the spec's `practice` only seeds new responses). Returns the state.
27
+ */
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;
31
+ /** Whether an option's terms carry a noticed prompt: its strongest pull and the opposing term. Pure. */
32
+ export declare function promptIn(terms: readonly Term[], tags: readonly string[] | undefined): {
33
+ source: string;
34
+ opposedBy: string;
35
+ } | undefined;
36
+ /**
37
+ * Extra terms for one option (cognition calls this before summing): the echo pull under `fault`, then the
38
+ * turning-away term when the option carries a prompt. Pure apart from reads; empty without the slice.
39
+ */
40
+ export declare function promptingTerms(p: Person, action: string, terms: readonly Term[], tags: readonly string[] | undefined, now: Minute, desperation: number): Term[];
41
+ /** A prompt found at a decision point, handed from cognition to the composite. */
42
+ export interface PromptingSeen {
43
+ affordanceId: string;
44
+ action: string;
45
+ source: string;
46
+ opposedBy: string;
47
+ /** Whether a turning-away term was applied. */
48
+ turnApplied: boolean;
49
+ }
50
+ /** What `notePromptings` did, for the composite (the `fault` guilt goes through affect). */
51
+ export interface PromptingsNoted {
52
+ records: PromptingRecord[];
53
+ /** Guilt intensity to feel at the prompt (only under `fault`); 0 otherwise. */
54
+ guilt: Unit;
55
+ }
56
+ /**
57
+ * Record the prompts of one decision point and learn from them. `chosenId` is the chosen affordance (null when
58
+ * nothing was chosen). The only writer of `p.promptings` after `enablePromptings`.
59
+ */
60
+ export declare function notePromptings(p: Person, seen: readonly PromptingSeen[], chosenId: string | null, now: Minute): PromptingsNoted;
61
+ /** Vocabulary for one prompt under a held reading (narration only; it changes nothing). */
62
+ export declare function promptingLine(reading: PromptingReading, outcome: PromptingRecord['outcome'], kind?: PromptingResponseKind): string;
63
+ /** Read-only summary of recent prompts (a lens; computes nothing new about the person). */
64
+ export interface PromptingsReadingView {
65
+ reading: PromptingReading;
66
+ readingSource?: string;
67
+ prompts: number;
68
+ turned: number;
69
+ passed: number;
70
+ assented: number;
71
+ responses: {
72
+ id: string;
73
+ kind: PromptingResponseKind;
74
+ practice: Unit;
75
+ }[];
76
+ /** Vocabulary for the last few prompts, newest last. */
77
+ lines: string[];
78
+ }
79
+ export declare function promptingsReading(p: Person, now?: Minute): PromptingsReadingView | undefined;
80
+ /**
81
+ * The nafs lens (reconciliation.md §3): the three Qur'anic descriptions of one self, each with the evidence in
82
+ * existing state that matches it. Descriptions, not a level, a ladder or three agents; several can hold at once,
83
+ * and none says anything about rank before God. Inputs are passed in by the composite (open-breach weight and
84
+ * felt guilt from conscience and affect), so this module reads nothing it does not own. Guilt that the `fault` reading
85
+ * feels at a prompt is excluded, so the scrupulous reading is not mapped onto self-reproach (75:2). These mappings
86
+ * from state to description are engineering conventions (C), not interpretations of the verses.
87
+ */
88
+ export interface NafsPattern {
89
+ name: 'urging' | 'reproaching' | 'at rest';
90
+ /** Qur'anic description it borrows (12:53 ammara, 75:2 lawwama, 89:27 mutmaʾinna). */
91
+ ref: string;
92
+ evident: boolean;
93
+ evidence: string;
94
+ }
95
+ export declare function nafsPatterns(p: Person, inputs: {
96
+ guiltLoad: Unit;
97
+ guilt: Unit;
98
+ }): NafsPattern[];
99
+ /** Validate a saved slice; a malformed slice is dropped (undefined), malformed entries are skipped. */
100
+ export declare function sanitizePromptings(input: unknown, now: Minute): PromptingsState | undefined;
@@ -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
+ }
package/dist/restore.js CHANGED
@@ -20,6 +20,7 @@ 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';
23
24
  import { sanitizeProspection } from './prospection/index.js';
24
25
  import { sanitizeSkillRetention } from './skills/index.js';
25
26
  import { sanitizeGroups, sanitizeImpressions } from './social/index.js';
@@ -61,6 +62,7 @@ const OPTIONAL_KEYS = new Set([
61
62
  'retention',
62
63
  'methods',
63
64
  'prospection',
65
+ 'promptings',
64
66
  ]);
65
67
  /** Kind of a JSON value for the per-field type check in `restore`. */
66
68
  const kindOf = (x) => (Array.isArray(x) ? 'array' : x === null ? 'null' : typeof x);
@@ -174,6 +176,7 @@ export function restore(input) {
174
176
  keepValid(out, 'retention', cleanRetention);
175
177
  keepValid(out, 'methods', sanitizeMethods);
176
178
  keepValid(out, 'prospection', (x) => sanitizeProspection(x, out.now, out.id));
179
+ keepValid(out, 'promptings', (x) => sanitizePromptings(x, out.now));
177
180
  boundLists(out);
178
181
  return out;
179
182
  }
package/dist/types.d.ts CHANGED
@@ -827,7 +827,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
827
827
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
828
828
  * `dutyReviewAt`). Person shape unchanged.
829
829
  */
830
- export declare const ENGINE_VERSION = "2.3.0";
830
+ export declare const ENGINE_VERSION = "2.4.0";
831
831
  /** A pregnancy carried by this person (host-driven; see `family/`). */
832
832
  export interface Pregnancy {
833
833
  fatherId: PersonId;
@@ -992,6 +992,8 @@ export interface Person {
992
992
  methods?: MethodsState;
993
993
  /** Received future consequences, absent until attended testimony supplies them. */
994
994
  prospection?: ProspectionState;
995
+ /** Noticed promptings and practised turnings-away (2.4.0, opt-in via `enablePromptings`). Owned by `promptings/`. */
996
+ promptings?: PromptingsState;
995
997
  /** Practice consolidation (1.8.0, opt-in via `enableSkillRetention`). Owned by `skills/`. */
996
998
  skillRetention?: {
997
999
  consolidationHours: number;
@@ -1295,7 +1297,8 @@ export interface SuggestionResolution {
1295
1297
  export interface Term {
1296
1298
  /** 'need:food', 'norm:salah', 'commitment:c3', 'commitment' (promise inertia of the running activity),
1297
1299
  * 'goal:g1', 'habit', 'emotion:fear:risky' (emotion id, then the tag it acts through), 'effort', 'risk',
1298
- * 'suggestion:player', 'social:p2', 'expectation', 'autonomy' (reactance to a pushing voice) */
1300
+ * 'suggestion:player', 'social:p2', 'expectation', 'autonomy' (reactance to a pushing voice),
1301
+ * 'promptings:turn' (a practised turning-away), 'promptings:echo' (recurrence under the `fault` reading) */
1299
1302
  source: string;
1300
1303
  value: number;
1301
1304
  }
@@ -1383,8 +1386,63 @@ export interface PersonSpec {
1383
1386
  yearbook?: boolean;
1384
1387
  character?: boolean;
1385
1388
  skillRetention?: boolean;
1389
+ /** Promptings and turning away (2.4.0): the same as `enablePromptings(p, spec)` after creation. */
1390
+ promptings?: PromptingsSpec;
1386
1391
  };
1387
1392
  }
1393
+ /**
1394
+ * How a person understands their own unwanted inner pulls (a held reading, set by the host, with provenance).
1395
+ * `whisper`, `inclination` and `intrusion` share mechanics and differ only in vocabulary; `fault` (the scrupulous
1396
+ * reading) adds guilt at the prompt itself; `none` is neutral vocabulary. See research/reconciliation.md §5.
1397
+ */
1398
+ export type PromptingReading = 'whisper' | 'inclination' | 'intrusion' | 'fault' | 'none';
1399
+ /** A practised turning-away: seeking refuge, redirecting attention, or simply leaving the thought. */
1400
+ export type PromptingResponseKind = 'refuge' | 'redirect' | 'leave';
1401
+ export interface PromptingResponse {
1402
+ id: string;
1403
+ kind: PromptingResponseKind;
1404
+ /** Skill-like practice 0..1 as of `lastUsedAt`; it decays slowly with disuse. */
1405
+ practice: Unit;
1406
+ lastUsedAt: Minute;
1407
+ }
1408
+ export interface PromptingRecord {
1409
+ at: Minute;
1410
+ action: string;
1411
+ affordanceId: string;
1412
+ /** The strongest pull: 'habit', 'emotion:*', 'need:*', 'promptings:echo' or 'host' (a `temptation` tag). */
1413
+ source: string;
1414
+ /** The opposing term: 'norm:x' or 'goal:g'. */
1415
+ opposedBy: string;
1416
+ outcome: 'passed' | 'turned' | 'assented';
1417
+ /** The response that was practised at this prompt, if any. */
1418
+ responseId?: string;
1419
+ }
1420
+ export interface PromptingsState {
1421
+ reading: PromptingReading;
1422
+ /** Provenance of the reading (upbringing, a teacher, a book id), as for held norms. */
1423
+ readingSource?: string;
1424
+ responses: PromptingResponse[];
1425
+ /** Recent prompts, oldest first, bounded (`PROMPTINGS_DEFAULTS.maxRecent`). */
1426
+ recent: PromptingRecord[];
1427
+ /** Recurrence per action under the `fault` reading (an off-by-default assumption; see `echoRate`). */
1428
+ echo: Record<string, {
1429
+ level: Unit;
1430
+ at: Minute;
1431
+ }>;
1432
+ /** Echo gained per prompt under `fault`. Default 0: the recurrence term is a host-set assumption (P3 contested). */
1433
+ echoRate: number;
1434
+ }
1435
+ /** What a host hands `enablePromptings`. */
1436
+ export interface PromptingsSpec {
1437
+ reading?: PromptingReading;
1438
+ readingSource?: string;
1439
+ responses?: {
1440
+ id: string;
1441
+ kind: PromptingResponseKind;
1442
+ practice?: Unit;
1443
+ }[];
1444
+ echoRate?: number;
1445
+ }
1388
1446
  /** What the body is doing over an interval. */
1389
1447
  export interface BodyLoad {
1390
1448
  effort: Unit;
package/dist/types.js CHANGED
@@ -120,4 +120,4 @@ export const PERSON_SCHEMA = 'human/person@1';
120
120
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
121
121
  * `dutyReviewAt`). Person shape unchanged.
122
122
  */
123
- export const ENGINE_VERSION = '2.3.0';
123
+ export const ENGINE_VERSION = '2.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Deterministic, explainable simulated people: body, needs, affect, memory, beliefs, skills, habits, relationships, values and a will that can refuse a suggestion",
5
5
  "keywords": [
6
6
  "simulation",