@adam0white/human-framework 2.9.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.
@@ -489,7 +489,7 @@ function deadlinePressure(g, now) {
489
489
  return 1 + AGENDA_DEFAULTS.goalDeadlineBoost * clamp01((now - g.adoptedAt) / span);
490
490
  }
491
491
  /** Shared existing amount and urgency reader; no future knowledge is consulted here. @internal */
492
- export function goalValue(g, aff, now) {
492
+ function goalValue(g, aff, now) {
493
493
  const amount = goalAmount(g, aff);
494
494
  return amount > 0
495
495
  ? g.importance * clamp01(amount * AGENDA_DEFAULTS.goalAmountGain) * deadlinePressure(g, now)
@@ -39,7 +39,29 @@ export interface AttendContext {
39
39
  }
40
40
  /** Attention as limited capacity: keep the K most relevant percepts plus any that cannot be ignored. */
41
41
  export declare function attend(p: Person, percepts: Percept[], ctx: AttendContext): Percept[];
42
+ /** Information-source trust, independent of relationship trust and advising-voice trust. */
42
43
  export declare function trustOf(p: Person, sourceId: EntityId): Unit;
44
+ /**
45
+ * Set authored trust in an information source, clamped to 0..1. Writes only `memory.sourceTrust`;
46
+ * it does not change relationship trust or advising-voice trust. Nonfinite trust and self ids throw.
47
+ * Later `believe` (including testimony through `perceive`) reads this trust; `confirm` recalibrates it.
48
+ */
49
+ export declare function seedSourceTrust(p: Person, sourceId: EntityId, trust: Unit): Unit;
50
+ /**
51
+ * Set or replace an authored belief without applying source trust or recording an observation.
52
+ * `credence` is the probability that `value` holds, clamped to 0..1 and stored as bounded signed
53
+ * log-odds. `at` defaults to the person's current minute. Nonfinite credence or time throws.
54
+ * Only `memory.beliefs` changes, with ordinary belief eviction. An omitted source leaves no sources;
55
+ * a named source records its claim for later `confirm`, without changing any trust store now. The person's own id
56
+ * and 'self' are refused (they mark observation): use `confirm` for something the person saw.
57
+ * It does not apply reputation to relationships; use `seedTie` to author a relationship.
58
+ */
59
+ export declare function seedBelief(p: Person, prop: string, spec: {
60
+ value: boolean;
61
+ credence: Unit;
62
+ at?: Minute;
63
+ source?: EntityId;
64
+ }): Belief;
43
65
  /** Testimony or inference: add reliability-weighted log-odds evidence for (or against) `prop`. */
44
66
  export declare function believe(p: Person, prop: string, value: boolean, confidence: Unit, sourceId: EntityId, now: Minute): Belief;
45
67
  export declare function credence(p: Person, prop: string): Unit;
@@ -8,6 +8,12 @@
8
8
  * gained, the trust-asymmetry finding). It does NOT model motivated reasoning, belief networks or
9
9
  * inference between propositions, source memory errors, or social consensus effects; parameters are
10
10
  * engineering defaults for game time scales.
11
+ *
12
+ * Three independent stores: `social.relationships[].trust` moves through authored ties, social events
13
+ * (including forgiveness), judged acts and reputation; `will.voices[].trust` moves through followed-advice
14
+ * outcomes, insisting, commands, `noteVoicePromise`, `seedVoice` and `adoptVoiceTrust` (also used by upbringing).
15
+ * This module owns `memory.sourceTrust`: `seedSourceTrust` authors it and `confirm` recalibrates it.
16
+ * `believe`, including testimony delivered by `perceive`, weights claims by source trust, read by `trustOf`.
11
17
  */
12
18
  import { clamp, clamp01, decay, dpow, expit, logit } from '../core/index.js';
13
19
  import { MINUTES_PER_DAY } from '../types.js';
@@ -97,11 +103,31 @@ export function attend(p, percepts, ctx) {
97
103
  }
98
104
  return out;
99
105
  }
106
+ /** Information-source trust, independent of relationship trust and advising-voice trust. */
100
107
  export function trustOf(p, sourceId) {
101
108
  if (sourceId === p.id || sourceId === 'self')
102
109
  return BELIEF_DEFAULTS.selfTrust;
103
110
  return p.memory.sourceTrust[sourceId] ?? BELIEF_DEFAULTS.defaultTrust;
104
111
  }
112
+ /**
113
+ * Set authored trust in an information source, clamped to 0..1. Writes only `memory.sourceTrust`;
114
+ * it does not change relationship trust or advising-voice trust. Nonfinite trust and self ids throw.
115
+ * Later `believe` (including testimony through `perceive`) reads this trust; `confirm` recalibrates it.
116
+ */
117
+ export function seedSourceTrust(p, sourceId, trust) {
118
+ if (!Number.isFinite(trust))
119
+ throw new Error('human: source trust must be finite');
120
+ if (sourceId === p.id || sourceId === 'self')
121
+ throw new Error('human: self trust cannot be seeded');
122
+ const value = clamp01(trust);
123
+ Object.defineProperty(p.memory.sourceTrust, sourceId, {
124
+ value,
125
+ writable: true,
126
+ enumerable: true,
127
+ configurable: true,
128
+ });
129
+ return value;
130
+ }
105
131
  /** Testimony weight from trust: 0 at or below the floor, never negative, so low trust alone never flips a claim. @internal */
106
132
  export function testimonyWeight(trust) {
107
133
  const d = BELIEF_DEFAULTS;
@@ -141,6 +167,31 @@ function ensureBelief(p, prop, now) {
141
167
  }
142
168
  return b;
143
169
  }
170
+ /**
171
+ * Set or replace an authored belief without applying source trust or recording an observation.
172
+ * `credence` is the probability that `value` holds, clamped to 0..1 and stored as bounded signed
173
+ * log-odds. `at` defaults to the person's current minute. Nonfinite credence or time throws.
174
+ * Only `memory.beliefs` changes, with ordinary belief eviction. An omitted source leaves no sources;
175
+ * a named source records its claim for later `confirm`, without changing any trust store now. The person's own id
176
+ * and 'self' are refused (they mark observation): use `confirm` for something the person saw.
177
+ * It does not apply reputation to relationships; use `seedTie` to author a relationship.
178
+ */
179
+ export function seedBelief(p, prop, spec) {
180
+ const at = spec.at ?? p.now;
181
+ if (!Number.isFinite(spec.credence))
182
+ throw new Error('human: belief credence must be finite');
183
+ if (!Number.isFinite(at))
184
+ throw new Error('human: belief time must be finite');
185
+ // The person's own id and 'self' mark direct observation (`observedDiscount`, `confirm`); authoring is not seeing.
186
+ if (spec.source === p.id || spec.source === 'self')
187
+ throw new Error('human: an authored belief cannot name the person as its source; use confirm for an observation');
188
+ const b = ensureBelief(p, prop, at);
189
+ const limit = BELIEF_DEFAULTS.maxLogOdds;
190
+ b.logOdds = clamp((spec.value ? 1 : -1) * logit(clamp01(spec.credence)), -limit, limit);
191
+ b.updatedAt = at;
192
+ b.sources = spec.source === undefined ? [] : [{ id: spec.source, value: spec.value }];
193
+ return b;
194
+ }
144
195
  /** Testimony or inference: add reliability-weighted log-odds evidence for (or against) `prop`. */
145
196
  export function believe(p, prop, value, confidence, sourceId, now) {
146
197
  const d = BELIEF_DEFAULTS;
@@ -225,7 +225,7 @@ export function socialContext(ctx) {
225
225
  };
226
226
  }
227
227
  /** Same perceived-body effort estimate for actual offers and received successors. @internal */
228
- export function effortValue(p, aff, ctx) {
228
+ function effortValue(p, aff, ctx) {
229
229
  const K = COGNITION_DEFAULTS;
230
230
  const per = ctx.body.perceived;
231
231
  const effort = clamp01(aff.effort);
@@ -239,7 +239,7 @@ export function effortValue(p, aff, ctx) {
239
239
  return -K.effortScale * effortCost;
240
240
  }
241
241
  /** Same material valuation for actual offers and received successor costs. @internal */
242
- export function materialValue(p, material, scarcity) {
242
+ function materialValue(p, material, scarcity) {
243
243
  const K = COGNITION_DEFAULTS;
244
244
  const v = p.values;
245
245
  const weight = (v.security + v.achievement + v.power) / 3;
@@ -100,9 +100,13 @@ export declare function recordDeed(p: Person, aff: Affordance, intention: string
100
100
  * The person repents a breach: regret, stopping and resolve. When another person was wronged,
101
101
  * restitution is also required, so the breach closes only after `recordRepair` for that victim.
102
102
  * Returns whether the breach is now closed. This records the person's acts, not acceptance.
103
+ * The unused `_now` parameter is optional since 2.9.1 and is removed in 3.0.
103
104
  */
104
- export declare function repent(p: Person, id: string, _now: Minute): boolean;
105
- /** Restitution/apology delivered to a wronged person (host-attested): closes their open breaches. */
106
- export declare function recordRepair(p: Person, victimId: string, _now: Minute): number;
105
+ export declare function repent(p: Person, id: string, _now?: Minute): boolean;
106
+ /**
107
+ * Restitution/apology delivered to a wronged person (host-attested): closes their open breaches.
108
+ * The unused `_now` parameter is optional since 2.9.1 and is removed in 3.0.
109
+ */
110
+ export declare function recordRepair(p: Person, victimId: string, _now?: Minute): number;
107
111
  /** Felt weight of open breaches, saturating: 1 - Π(1 - weight). Input for guilt in affect. */
108
112
  export declare function guiltLoad(p: Person): Unit;
@@ -351,6 +351,7 @@ export function recordDeed(p, aff, intention, now, completed, opts = {}) {
351
351
  * The person repents a breach: regret, stopping and resolve. When another person was wronged,
352
352
  * restitution is also required, so the breach closes only after `recordRepair` for that victim.
353
353
  * Returns whether the breach is now closed. This records the person's acts, not acceptance.
354
+ * The unused `_now` parameter is optional since 2.9.1 and is removed in 3.0.
354
355
  */
355
356
  export function repent(p, id, _now) {
356
357
  const breach = p.conscience.breaches.find((b) => b.id === id);
@@ -361,7 +362,10 @@ export function repent(p, id, _now) {
361
362
  breach.repaired = true;
362
363
  return true;
363
364
  }
364
- /** Restitution/apology delivered to a wronged person (host-attested): closes their open breaches. */
365
+ /**
366
+ * Restitution/apology delivered to a wronged person (host-attested): closes their open breaches.
367
+ * The unused `_now` parameter is optional since 2.9.1 and is removed in 3.0.
368
+ */
365
369
  export function recordRepair(p, victimId, _now) {
366
370
  let count = 0;
367
371
  for (const b of p.conscience.breaches) {
@@ -5,6 +5,7 @@ export declare const clampSigned: (value: number) => number;
5
5
  export declare const lerp: (a: number, b: number, t: number) => number;
6
6
  export declare const sigmoid: (x: number) => number;
7
7
  export declare const logit: (p: number) => number;
8
+ /** @deprecated since 2.9.1, removed in 3.0; use sigmoid */
8
9
  export declare const expit: typeof sigmoid;
9
10
  /** Exponential decay of `value` toward `target` over `dt` minutes with the given half-life. */
10
11
  export declare function decay(value: number, dt: number, halfLife: number, target?: number): number;
package/dist/core/math.js CHANGED
@@ -36,6 +36,7 @@ export const logit = (p) => {
36
36
  const q = clamp(p, 1e-6, 1 - 1e-6);
37
37
  return dlog(q / (1 - q));
38
38
  };
39
+ /** @deprecated since 2.9.1, removed in 3.0; use sigmoid */
39
40
  export const expit = sigmoid;
40
41
  /** Exponential decay of `value` toward `target` over `dt` minutes with the given half-life. */
41
42
  export function decay(value, dt, halfLife, target = 0) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- /** Human Framework v1 public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.9.0";
1
+ /** Human Framework public surface. */
2
+ export declare const FRAMEWORK_VERSION = "2.9.1";
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';
@@ -31,7 +31,7 @@ export * from './person.ts';
31
31
  export * from './promptings/index.ts';
32
32
  export type { ConsequenceEvidence } from './prospection/index.ts';
33
33
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, FORECAST_DEFAULTS, forecastLine, forecastThresholds, PROSPECTION_DEFAULTS, } from './prospection/index.ts';
34
- export * from './restore.ts';
34
+ export { fingerprint, restore, snapshot } from './restore.ts';
35
35
  export * from './review/index.ts';
36
36
  export * from './scenarios/index.ts';
37
37
  export * from './sim/index.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- /** Human Framework v1 public surface. */
2
- export const FRAMEWORK_VERSION = '2.9.0';
1
+ /** Human Framework public surface. */
2
+ export const FRAMEWORK_VERSION = '2.9.1';
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';
@@ -27,7 +27,7 @@ export * from './partnering/index.js';
27
27
  export * from './person.js';
28
28
  export * from './promptings/index.js';
29
29
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, FORECAST_DEFAULTS, forecastLine, forecastThresholds, PROSPECTION_DEFAULTS, } from './prospection/index.js';
30
- export * from './restore.js';
30
+ export { fingerprint, restore, snapshot } from './restore.js';
31
31
  export * from './review/index.js';
32
32
  export * from './scenarios/index.js';
33
33
  export * from './sim/index.js';
package/dist/migrate.js CHANGED
@@ -8,7 +8,17 @@
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. 2.3.0 to 2.4.0 stamps the version; promptings remain absent unless enabled. 2.4.0 to 2.5.0 stamps the version; the bodily forecast reads only offers that declare `followedBy`. 2.5.0 to 2.6.0 stamps the version; the fatigue store stays absent unless enabled. 2.6.0 to 2.7.0 stamps the version; the evening review stays absent unless enabled. 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.
12
+ * 2.1.0 to 2.2.0 preserves optional method records and stamps the version; successful alternatives now
13
+ * retain independent support. 2.2.0 to 2.3.0 stamps the version; received consequences and temporal goals
14
+ * remain absent unless explicitly supplied. 2.3.0 to 2.4.0 stamps the version; promptings remain absent
15
+ * unless enabled. 2.4.0 to 2.5.0 stamps the version; the bodily forecast reads only offers that declare
16
+ * `followedBy`. 2.5.0 to 2.6.0 stamps the version; the fatigue store stays absent unless enabled. 2.6.0 to
17
+ * 2.7.0 stamps the version; the evening review stays absent unless enabled. 2.7.0 to 2.8.0 stamps the
18
+ * version; fullness stays absent unless enabled, and alongside partners and reconsideration are opt-in.
19
+ * 2.8.0 to 2.9.0 stamps the version; prompting cues and practice half-lives stay absent unless supplied,
20
+ * review terms may carry refs, and an outcome naming several commitments now keeps each rather than only
21
+ * the earliest-ending one. A migrated save continues under the current engine's rules: it restores and runs,
12
22
  * 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
23
  * 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
24
  *
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)
@@ -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;
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
  /**
package/dist/sim/sim.js CHANGED
@@ -125,6 +125,7 @@ export function communityState(c) {
125
125
  /**
126
126
  * A community over `people`. With `prior` (from `communityState`, possibly through JSON), the host-side state is
127
127
  * resumed from it, so a run restored from people + community + world state continues exactly as it would have.
128
+ * `prior` is copied: after stepping, write back `communityState(c)`; the old `prior` stays stale.
128
129
  */
129
130
  export function createCommunity(people, prior) {
130
131
  const sorted = [...people].sort((a, b) => cmpStr(a.id, b.id));
@@ -181,9 +182,19 @@ export function birth(c, a, b, spec) {
181
182
  /**
182
183
  * Tell `dead`'s death at `at` to everyone alive in the community who has a tie to them (1.8.0): a told `death`
183
184
  * percept through `perceive`, so they grieve, the tie is kept and marked, and a spouse is widowed. Each listener's
184
- * clock must be at or before `at`; the drivers call it when they notice the death.
185
+ * receipt time is at least their own `now`. `late: 'now'` explicitly permits listeners past `at`, as does the
186
+ * existing default. `late: 'throw'` requires all eligible listeners to be at or before `at` and throws before
187
+ * delivering any news if one is ahead. This changes no listener's clock.
185
188
  */
186
- export function tellDeath(c, dead, at) {
189
+ export function tellDeath(c, dead, at, opts = {}) {
190
+ if (opts.late === 'throw') {
191
+ for (const q of c.people) {
192
+ if (q === dead || !q.body.alive || q.now <= at)
193
+ continue;
194
+ if (q.social.relationships.some((r) => r.otherId === dead.id))
195
+ throw new Error(`tellDeath: listener ${q.id} is past the death minute`);
196
+ }
197
+ }
187
198
  for (const q of c.people) {
188
199
  if (q === dead || !q.body.alive)
189
200
  continue;
@@ -572,24 +583,8 @@ function contagionBetween(p, q, act, t, log) {
572
583
  }
573
584
  }
574
585
  }
575
- /**
576
- * Advance the community to `until`, processing person events in (time, id) order. Returns the events.
577
- */
578
- export function stepCommunity(c, world, until, opts = {}) {
579
- const events = [];
580
- const quantum = Math.max(1, opts.idleQuantum ?? SIM_DEFAULTS.idleQuantum);
581
- const maxEvents = opts.maxEvents ?? SIM_DEFAULTS.maxEvents;
582
- const salience = opts.interruptSalience ?? SIM_DEFAULTS.interruptSalience;
583
- // Communities created before joint support (plain JSON from an older host) get the new bookkeeping.
584
- c.interrupts ??= {};
585
- c.joint ??= { nextId: 0, proposals: [], excluded: {} };
586
- const log = (e) => {
587
- if (events.length < maxEvents)
588
- events.push(e);
589
- };
590
- const contagion = opts.contagion ?? true;
591
- c.queued ??= {};
592
- c.dayDone ??= {};
586
+ /** Synchronize the host's commands before selecting the next event. */
587
+ function synchronizeCommands(c, opts, log) {
593
588
  if (opts.controlled) {
594
589
  for (const p of c.people) {
595
590
  const cmd = opts.controlled[p.id];
@@ -614,36 +609,108 @@ export function stepCommunity(c, world, until, opts = {}) {
614
609
  }
615
610
  }
616
611
  }
617
- const decideOpts = (p, reason, affordances) => {
618
- const o = {};
619
- // Voices: queued conversation advice first (fresher), then the host's standing suggestions that are heard now
620
- // (standing-advice SCOPE); one per voice. At reviews they still weigh in, but `decide` resolves them quietly
621
- // (no counters, pressure or autonomy cost), so one request is not re-counted as many verdicts.
622
- const standing = opts.suggestions?.[p.id];
623
- const merged = [];
624
- const seen = new Set();
625
- const heard = (standing ? (Array.isArray(standing) ? standing : [standing]) : []).filter((s) => standingHeard(c, p, s, affordances, p.now));
626
- for (const s of [...(c.queued?.[p.id] ?? []), ...heard]) {
627
- if (seen.has(s.voiceId))
628
- continue;
629
- seen.add(s.voiceId);
630
- merged.push(s);
612
+ }
613
+ /** Run life-course bookkeeping and the host's day hook at the first event of a new day. */
614
+ function bookkeepDay(p, world, t, dayDone, opts) {
615
+ // First event of a new day: life-course rolls for the days crossed, then the host's day hook.
616
+ const day = dayOf(t);
617
+ const lastDay = dayDone[p.id];
618
+ if (lastDay === undefined || day > lastDay) {
619
+ dayDone[p.id] = day;
620
+ const lc = opts.lifecourse;
621
+ if (lc && lastDay !== undefined && p.body.alive) {
622
+ const dt = (day - lastDay) * MINUTES_PER_DAY;
623
+ if (lc.chronicOnsets) {
624
+ const onsetOpts = {};
625
+ if (lc.conditions)
626
+ onsetOpts.conditions = lc.conditions;
627
+ const ex = lc.exposures?.(p);
628
+ if (ex)
629
+ onsetOpts.exposures = ex;
630
+ for (const ill of chronicOnsets(p, dt, p.rng, onsetOpts))
631
+ sicken(p, ill);
632
+ }
633
+ if (lc.mortality) {
634
+ const roll = mortalityEvent(p, dt, p.rng, lc.multiplier !== undefined ? { multiplier: lc.multiplier } : {});
635
+ if (roll.died)
636
+ die(p);
637
+ }
631
638
  }
632
- const [first, ...rest] = merged;
633
- if (first)
634
- o.suggestion = first;
635
- if (rest.length > 0)
636
- o.suggestions = rest;
637
- const scarcity = world.scarcityFor?.(p);
638
- if (scarcity !== undefined && scarcity > 0)
639
- o.scarcity = scarcity;
640
- if (opts.necessity !== undefined)
641
- o.necessity = opts.necessity;
642
- const forced = reason ?? c.interrupts[p.id]?.reason;
643
- if (forced !== undefined && !p.activity)
644
- o.interrupt = forced;
645
- return o;
639
+ if (p.body.alive)
640
+ world.onDay?.(p, day);
641
+ }
642
+ }
643
+ /** Merge heard advice and the host's decision options in their existing precedence order. */
644
+ function decisionOptions(c, world, p, opts, reason, affordances) {
645
+ const o = {};
646
+ // Voices: queued conversation advice first (fresher), then the host's standing suggestions that are heard now
647
+ // (standing-advice SCOPE); one per voice. At reviews they still weigh in, but `decide` resolves them quietly
648
+ // (no counters, pressure or autonomy cost), so one request is not re-counted as many verdicts.
649
+ const standing = opts.suggestions?.[p.id];
650
+ const merged = [];
651
+ const seen = new Set();
652
+ const heard = (standing ? (Array.isArray(standing) ? standing : [standing]) : []).filter((s) => standingHeard(c, p, s, affordances, p.now));
653
+ for (const s of [...(c.queued?.[p.id] ?? []), ...heard]) {
654
+ if (seen.has(s.voiceId))
655
+ continue;
656
+ seen.add(s.voiceId);
657
+ merged.push(s);
658
+ }
659
+ const [first, ...rest] = merged;
660
+ if (first)
661
+ o.suggestion = first;
662
+ if (rest.length > 0)
663
+ o.suggestions = rest;
664
+ const scarcity = world.scarcityFor?.(p);
665
+ if (scarcity !== undefined && scarcity > 0)
666
+ o.scarcity = scarcity;
667
+ if (opts.necessity !== undefined)
668
+ o.necessity = opts.necessity;
669
+ const forced = reason ?? c.interrupts[p.id]?.reason;
670
+ if (forced !== undefined && !p.activity)
671
+ o.interrupt = forced;
672
+ return o;
673
+ }
674
+ /** Add pending joint mirrors to world offers and apply this minute's declined-offer exclusions. */
675
+ function offersForDecision(c, world, p, t) {
676
+ // Offers: the world's, plus mirrors of joint proposals awaiting this person's answer, minus offers a partner
677
+ // just declined (this minute only).
678
+ const asked = pendingProposals(c).filter((x) => x.partnerIds.includes(p.id) && !x.accepted.includes(p.id));
679
+ const mirrors = new Map();
680
+ let affordances = world.affordancesFor(p);
681
+ for (const x of asked) {
682
+ const m = mirrorFor(world, p, x);
683
+ mirrors.set(m.id, x);
684
+ affordances = [...affordances.filter((a) => a.id !== m.id), m];
685
+ }
686
+ const ex = c.joint.excluded[p.id];
687
+ if (ex) {
688
+ if (ex.at === t)
689
+ affordances = affordances.filter((a) => !ex.ids.includes(a.id));
690
+ else
691
+ delete c.joint.excluded[p.id];
692
+ }
693
+ return { asked, mirrors, affordances };
694
+ }
695
+ /**
696
+ * Advance the community to `until`, processing person events in (time, id) order. Returns the events.
697
+ */
698
+ export function stepCommunity(c, world, until, opts = {}) {
699
+ const events = [];
700
+ const quantum = Math.max(1, opts.idleQuantum ?? SIM_DEFAULTS.idleQuantum);
701
+ const maxEvents = opts.maxEvents ?? SIM_DEFAULTS.maxEvents;
702
+ const salience = opts.interruptSalience ?? SIM_DEFAULTS.interruptSalience;
703
+ // Communities created before joint support (plain JSON from an older host) get the new bookkeeping.
704
+ c.interrupts ??= {};
705
+ c.joint ??= { nextId: 0, proposals: [], excluded: {} };
706
+ const log = (e) => {
707
+ if (events.length < maxEvents)
708
+ events.push(e);
646
709
  };
710
+ const contagion = opts.contagion ?? true;
711
+ c.queued ??= {};
712
+ c.dayDone ??= {};
713
+ synchronizeCommands(c, opts, log);
647
714
  // Pairs whose shared activity already ran its conversation and contagion at `pairMinute` (both partners
648
715
  // finishing the same joint activity at the same minute would otherwise run them twice).
649
716
  let pairMinute = Number.NEGATIVE_INFINITY;
@@ -662,33 +729,7 @@ export function stepCommunity(c, world, until, opts = {}) {
662
729
  break;
663
730
  const p = next;
664
731
  tick(p, t);
665
- // First event of a new day: life-course rolls for the days crossed, then the host's day hook.
666
- const day = dayOf(t);
667
- const lastDay = c.dayDone[p.id];
668
- if (lastDay === undefined || day > lastDay) {
669
- c.dayDone[p.id] = day;
670
- const lc = opts.lifecourse;
671
- if (lc && lastDay !== undefined && p.body.alive) {
672
- const dt = (day - lastDay) * MINUTES_PER_DAY;
673
- if (lc.chronicOnsets) {
674
- const onsetOpts = {};
675
- if (lc.conditions)
676
- onsetOpts.conditions = lc.conditions;
677
- const ex = lc.exposures?.(p);
678
- if (ex)
679
- onsetOpts.exposures = ex;
680
- for (const ill of chronicOnsets(p, dt, p.rng, onsetOpts))
681
- sicken(p, ill);
682
- }
683
- if (lc.mortality) {
684
- const roll = mortalityEvent(p, dt, p.rng, lc.multiplier !== undefined ? { multiplier: lc.multiplier } : {});
685
- if (roll.died)
686
- die(p);
687
- }
688
- }
689
- if (p.body.alive)
690
- world.onDay?.(p, day);
691
- }
732
+ bookkeepDay(p, world, t, c.dayDone, opts);
692
733
  if (!p.body.alive) {
693
734
  log({ at: t, personId: p.id, kind: 'died', detail: `${p.name} died` });
694
735
  if (opts.tellDeaths ?? true)
@@ -758,24 +799,8 @@ export function stepCommunity(c, world, until, opts = {}) {
758
799
  }
759
800
  act = p.activity;
760
801
  }
761
- // Offers: the world's, plus mirrors of joint proposals awaiting this person's answer, minus offers a partner
762
- // just declined (this minute only).
763
- const asked = pendingProposals(c).filter((x) => x.partnerIds.includes(p.id) && !x.accepted.includes(p.id));
764
- const mirrors = new Map();
765
- let affordances = world.affordancesFor(p);
766
- for (const x of asked) {
767
- const m = mirrorFor(world, p, x);
768
- mirrors.set(m.id, x);
769
- affordances = [...affordances.filter((a) => a.id !== m.id), m];
770
- }
771
- const ex = c.joint.excluded[p.id];
772
- if (ex) {
773
- if (ex.at === t)
774
- affordances = affordances.filter((a) => !ex.ids.includes(a.id));
775
- else
776
- delete c.joint.excluded[p.id];
777
- }
778
- const record = decide(p, affordances, decideOpts(p, perceptReason, affordances));
802
+ const { asked, mirrors, affordances } = offersForDecision(c, world, p, t);
803
+ const record = decide(p, affordances, decisionOptions(c, world, p, opts, perceptReason, affordances));
779
804
  delete c.interrupts[p.id];
780
805
  // Conversation advice is weighed until the next fresh decision, then it has been heard.
781
806
  if (!act)
package/dist/will/will.js CHANGED
@@ -562,10 +562,49 @@ const voiceTerm = (c, voiceId) => c?.terms.find((t) => t.source === `suggestion:
562
562
  * group-advice findings only in shape (advice is weighed by trust in each adviser, not by headcount); it does not model conformity to a majority, persuasion between the voices, or the person asking for advice.
563
563
  */
564
564
  function evaluate(p, input, ctx, given, draw) {
565
- const W = WILL_DEFAULTS;
566
565
  const cmd = ctx.command;
567
566
  // A commanding voice is not also weighed as a suggestion in the same decision.
568
567
  const suggestions = cmd ? given.filter((s) => s.voiceId !== cmd.voiceId) : given;
568
+ const prepared = prepareVetoes(p, input, ctx, suggestions);
569
+ const { considered, affById } = prepared;
570
+ const selection = selectOption(p, considered, ctx, draw);
571
+ const ev = {
572
+ chosenId: selection.winner?.affordanceId ?? null,
573
+ considered,
574
+ resolutions: [],
575
+ autonomyDelta: 0,
576
+ voices: [],
577
+ };
578
+ if (cmd && arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev))
579
+ return ev;
580
+ if (suggestions.length === 0)
581
+ return withCommandRefusal(ev, cmd);
582
+ if (inBreak(p)) {
583
+ // During a mental break no voice reaches them; nothing is counted for or against any of them.
584
+ ev.resolutions = suggestions.map((s) => ({
585
+ voiceId: s.voiceId,
586
+ verdict: 'refused',
587
+ kind: 'cannot',
588
+ reason: 'break',
589
+ says: '',
590
+ }));
591
+ ev.voices = suggestions.map((s) => ({ id: s.voiceId, pressure: 0, accepted: 0, refused: 0 }));
592
+ if (ev.resolutions.length === 1)
593
+ ev.suggestion = ev.resolutions[0];
594
+ return withCommandRefusal(ev, cmd);
595
+ }
596
+ const judged = judgeVoices(p, ctx, suggestions, prepared, selection);
597
+ arbitrateInsistence(p, judged, considered, affById, ev);
598
+ ev.resolutions = judged.map((j) => j.res);
599
+ ev.voices = judged.map((j) => j.side);
600
+ const chosen = considered.find((c) => c.affordanceId === ev.chosenId);
601
+ ev.suggestion =
602
+ creditedResolution(ev.resolutions, chosen) ??
603
+ (ev.resolutions.length === 1 ? ev.resolutions[0] : undefined);
604
+ return withCommandRefusal(ev, cmd);
605
+ }
606
+ /** Apply capacity, conscience, per-voice distrust and omission vetoes, in that order. */
607
+ function prepareVetoes(p, input, ctx, suggestions) {
569
608
  const affById = new Map(ctx.affordances.map((a) => [a.id, a]));
570
609
  const duties = closingDuties(p, ctx);
571
610
  const servesDuty = (aff) => duties.some((c) => keepsDuty(p, c, aff, ctx.now));
@@ -609,6 +648,11 @@ function evaluate(p, input, ctx, given, draw) {
609
648
  out.vetoed = veto;
610
649
  return out;
611
650
  });
651
+ return { affById, duties, baseVeto, distrustOf, considered };
652
+ }
653
+ /** Select once using the supplied draw; prediction passes no draw and stays deterministic. */
654
+ function selectOption(p, considered, ctx, draw) {
655
+ const W = WILL_DEFAULTS;
612
656
  // Selection: inertia for the current activity, then argmax or softmax.
613
657
  const live = considered.filter((c) => !c.vetoed);
614
658
  const current = p.activity;
@@ -636,67 +680,55 @@ function evaluate(p, input, ctx, given, draw) {
636
680
  }
637
681
  }
638
682
  }
639
- const ev = {
640
- chosenId: winner?.affordanceId ?? null,
641
- considered,
642
- resolutions: [],
643
- autonomyDelta: 0,
644
- voices: [],
645
- };
646
- if (cmd) {
647
- const co = commandOutcome(p, considered, ctx, cmd, winner, score, baseVeto, duties);
648
- ev.command = co;
649
- if (co.holds && co.targetAffordanceId !== undefined) {
650
- // Direct control: the commanded option is done; every other voice is set aside (no counters) and a
651
- // distrust veto another voice caused does not stand in the way.
652
- ev.chosenId = co.targetAffordanceId;
653
- for (const c of considered)
654
- if (c.vetoed?.reason === 'distrust')
655
- delete c.vetoed;
656
- const res = {
657
- voiceId: cmd.voiceId,
658
- verdict: 'commanded',
659
- reason: 'command',
660
- says: '',
661
- margin: co.margin,
662
- };
663
- if (co.ownAffordanceId !== null && co.ownAffordanceId !== co.targetAffordanceId)
664
- res.insteadAffordanceId = co.ownAffordanceId;
665
- const all = [
666
- res,
667
- ...suggestions.map((s) => ({
668
- voiceId: s.voiceId,
669
- verdict: 'refused',
670
- kind: 'cannot',
671
- reason: 'commanded',
672
- says: '',
673
- })),
674
- ].sort((a, b) => cmpStr(a.voiceId, b.voiceId));
675
- ev.resolutions = all;
676
- ev.voices = all.map((r) => ({ id: r.voiceId, pressure: 0, accepted: 0, refused: 0 }));
677
- ev.suggestion = res;
678
- return ev;
679
- }
680
- }
681
- if (suggestions.length === 0)
682
- return withCommandRefusal(ev, cmd);
683
- if (inBreak(p)) {
684
- // During a mental break no voice reaches them; nothing is counted for or against any of them.
685
- ev.resolutions = suggestions.map((s) => ({
686
- voiceId: s.voiceId,
687
- verdict: 'refused',
688
- kind: 'cannot',
689
- reason: 'break',
683
+ return { winner, ranked, score };
684
+ }
685
+ /** Return true when a command holds and completes the evaluation ahead of all suggestions. */
686
+ function arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev) {
687
+ const { considered, baseVeto, duties } = prepared;
688
+ const { winner, score } = selection;
689
+ const co = commandOutcome(p, considered, ctx, cmd, winner, score, baseVeto, duties);
690
+ ev.command = co;
691
+ if (co.holds && co.targetAffordanceId !== undefined) {
692
+ // Direct control: the commanded option is done; every other voice is set aside (no counters) and a
693
+ // distrust veto another voice caused does not stand in the way.
694
+ ev.chosenId = co.targetAffordanceId;
695
+ for (const c of considered)
696
+ if (c.vetoed?.reason === 'distrust')
697
+ delete c.vetoed;
698
+ const res = {
699
+ voiceId: cmd.voiceId,
700
+ verdict: 'commanded',
701
+ reason: 'command',
690
702
  says: '',
691
- }));
692
- ev.voices = suggestions.map((s) => ({ id: s.voiceId, pressure: 0, accepted: 0, refused: 0 }));
693
- if (ev.resolutions.length === 1)
694
- ev.suggestion = ev.resolutions[0];
695
- return withCommandRefusal(ev, cmd);
703
+ margin: co.margin,
704
+ };
705
+ if (co.ownAffordanceId !== null && co.ownAffordanceId !== co.targetAffordanceId)
706
+ res.insteadAffordanceId = co.ownAffordanceId;
707
+ const all = [
708
+ res,
709
+ ...suggestions.map((s) => ({
710
+ voiceId: s.voiceId,
711
+ verdict: 'refused',
712
+ kind: 'cannot',
713
+ reason: 'commanded',
714
+ says: '',
715
+ })),
716
+ ].sort((a, b) => cmpStr(a.voiceId, b.voiceId));
717
+ ev.resolutions = all;
718
+ ev.voices = all.map((r) => ({ id: r.voiceId, pressure: 0, accepted: 0, refused: 0 }));
719
+ ev.suggestion = res;
720
+ return true;
696
721
  }
722
+ return false;
723
+ }
724
+ /** Judge each voice against the same selected option before resolving competing insistence. */
725
+ function judgeVoices(p, ctx, suggestions, prepared, selection) {
726
+ const W = WILL_DEFAULTS;
727
+ const { considered, affById, distrustOf } = prepared;
728
+ const { winner, ranked, score } = selection;
697
729
  const winnerAff = winner ? affById.get(winner.affordanceId) : undefined;
698
730
  const reason = dominantTerm(winner);
699
- const judged = suggestions.map((s) => {
731
+ return suggestions.map((s) => {
700
732
  const suggested = considered.filter((c) => {
701
733
  const aff = affById.get(c.affordanceId);
702
734
  return aff !== undefined && suggestionTargets(s, aff);
@@ -815,6 +847,10 @@ function evaluate(p, input, ctx, given, draw) {
815
847
  : { affordanceId: bestSuggested.affordanceId, label: 'later' },
816
848
  }));
817
849
  });
850
+ }
851
+ /** Settle competing insistence in the existing voice order, preserving equal-trust/strength ties. */
852
+ function arbitrateInsistence(p, judged, considered, affById, ev) {
853
+ const W = WILL_DEFAULTS;
818
854
  // Rule (a): at most one insisted option is complied with.
819
855
  const insisting = judged.filter((j) => j.comply !== undefined);
820
856
  let forcer;
@@ -882,13 +918,6 @@ function evaluate(p, input, ctx, given, draw) {
882
918
  }
883
919
  }
884
920
  }
885
- ev.resolutions = judged.map((j) => j.res);
886
- ev.voices = judged.map((j) => j.side);
887
- const chosen = considered.find((c) => c.affordanceId === ev.chosenId);
888
- ev.suggestion =
889
- creditedResolution(ev.resolutions, chosen) ??
890
- (ev.resolutions.length === 1 ? ev.resolutions[0] : undefined);
891
- return withCommandRefusal(ev, cmd);
892
921
  }
893
922
  /** Add the commanding voice's refusal (a command that did not hold) to the resolutions, in voice-id order. */
894
923
  function withCommandRefusal(ev, cmd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
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",