@adam0white/human-framework 2.12.0 → 2.13.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.
@@ -154,8 +154,11 @@ function sanitizeGoalTemporal(g) {
154
154
  else if (g.temporal)
155
155
  g.temporal = { halfLifeMinutes: g.temporal.halfLifeMinutes };
156
156
  }
157
- /** Restore-time check of `agenda.lapse` (1.7.0), in place: a malformed one is dropped (absent means none). @internal */
157
+ /** Restore-time check of optional lapse and firm flags; malformed values are dropped. @internal */
158
158
  export function sanitizeAgenda(a) {
159
+ for (const c of a.commitments)
160
+ if (isObj(c) && (c.firm !== true || c.kind !== 'abstain'))
161
+ delete c.firm;
159
162
  for (const g of a.goals)
160
163
  if (isObj(g))
161
164
  sanitizeGoalTemporal(g);
@@ -22,7 +22,10 @@
22
22
  * desperation threshold, and only for acts meeting a bodily need; this is not the fast's own exemption for
23
23
  * illness and travel (2:184-185, applied in the agenda) and not the 2:173 necessity exception for forbidden
24
24
  * food. Below the threshold the abstention acts only as a negative term (agenda), so a weakly convinced person
25
- * can be tempted. It does not model the person's later judgment of a broken fast beyond the recorded breach.
25
+ * can be tempted. An opt-in `firm: true` self abstention without a linked obligatory norm instead vetoes
26
+ * as `rule:<label>` (id fallback). It uses the same open-window, exemption and bodily survival checks,
27
+ * including for self choice and insistence. It models a held rule, not a new religious ruling.
28
+ * It does not model the person's later judgment of a broken fast beyond the recorded breach.
26
29
  */
27
30
  import { clamp01 } from '../core/index.js';
28
31
  import { DEFAULT_NORMS, NORM_SCOPE } from './catalog.js';
@@ -251,11 +254,15 @@ export function abstentionVeto(p, aff, now, capacityLifted = false) {
251
254
  return undefined;
252
255
  const end = now + Math.max(0, aff.duration);
253
256
  for (const c of p.agenda?.commitments ?? []) {
254
- if (c.kind !== 'abstain' || c.status !== 'pending' || c.exempt !== undefined || c.normId === undefined)
257
+ if (c.kind !== 'abstain' || c.status !== 'pending' || c.exempt !== undefined)
255
258
  continue;
256
259
  if (!(c.violatedBy?.includes(aff.action) ?? false) || now >= c.until || end <= c.from)
257
260
  continue;
258
- const held = heldNorm(p, c.normId);
261
+ const held = c.normId === undefined ? undefined : heldNorm(p, c.normId);
262
+ if (c.firm === true &&
263
+ (c.toId === undefined || c.toId === 'self' || c.toId === p.id) &&
264
+ held?.standing !== 'obligatory')
265
+ return { kind: 'willNot', reason: `rule:${c.label ?? c.id}` };
259
266
  if (held?.standing !== 'obligatory' || held.conviction < CONSCIENCE_DEFAULTS.abstainVetoConviction)
260
267
  continue;
261
268
  return { kind: 'willNot', reason: `duty:${c.normId}` };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.12.0";
2
+ export declare const FRAMEWORK_VERSION = "2.13.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';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework public surface. */
2
- export const FRAMEWORK_VERSION = '2.12.0';
2
+ export const FRAMEWORK_VERSION = '2.13.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';
package/dist/migrate.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * version; fullness stays absent unless enabled, and alongside partners and reconsideration are opt-in.
19
19
  * 2.8.0 to 2.9.0 stamps the version; prompting cues and practice half-lives stay absent unless supplied,
20
20
  * review terms may carry refs, and an outcome naming several commitments now keeps each rather than only
21
- * the earliest-ending one. 2.11.0 to 2.12.0 stamps the version (planning starts absent). 2.10.0 to 2.11.0 stamps the version (curiosity starts absent). 2.9.0 to 2.10.0 stamps the version; belief provenance, noted contradictions and
21
+ * the earliest-ending one. 2.12.0 to 2.13.0 stamps the version (suggestion reasons and firm rules start absent). 2.11.0 to 2.12.0 stamps the version (planning starts absent). 2.10.0 to 2.11.0 stamps the version (curiosity starts absent). 2.9.0 to 2.10.0 stamps the version; belief provenance, noted contradictions and
22
22
  * believed regard stay absent unless a host supplies them;
23
23
  * `regard` becomes a reserved impression cue key. A migrated save continues under the current engine's rules: it restores and runs,
24
24
  * but it does not reproduce what the old engine would have done next where the rules changed (1.4.0 to 1.5.0
@@ -70,6 +70,11 @@ export const MIGRATIONS = {
70
70
  note: 'the surroundings field `ambient.now` is renamed `ambient.percept`; per-person retention is optional and starts absent; behaviour unchanged',
71
71
  apply: renameAmbient,
72
72
  },
73
+ '2.12.0': {
74
+ to: '2.13.0',
75
+ note: 'suggestion reasons (Person.will.suggestionReasons) and firm self-rules (Commitment.firm) are optional and start absent; existing hosts retain exact behavior',
76
+ apply: stamp,
77
+ },
73
78
  '2.11.0': {
74
79
  to: '2.12.0',
75
80
  note: 'the planning slice (Person.planning) is optional and starts absent; retained consequences may carry channel known only when seeded; existing hosts retain exact behavior',
package/dist/person.js CHANGED
@@ -43,7 +43,7 @@ import { enableReview, reviewIfDue } from './review/index.js';
43
43
  import { enableSkillRetention, learnByWatching, practise, seedSkills, } from './skills/index.js';
44
44
  import { advanceSocial, applyReputationBelief, careFor, closeness, judge, markDeceased, noteKnownBy, relationshipWith, seedRelationships, socialEvent, threatAppraisal, } from './social/index.js';
45
45
  import { ENGINE_VERSION, MAX_MINUTE, PERSON_SCHEMA, PHYSIOLOGICAL_NEEDS, PSYCHOLOGICAL_NEEDS, TRAIT_KEYS, VALUE_KEYS, } from './types.js';
46
- import { advanceWill, answerSuggestion, chargeCommand, commandTargets, createWill, creditedVoices, dischargeAdvice, dutyReviewAt, endCommand, learnFromVoice, noteCommandOutcome, predictResponse, rememberAdvice, resolveCommand, takeCommand, voicesIn, WILL_DEFAULTS, wakeReviewAt, } from './will/index.js';
46
+ import { advanceWill, answerSuggestion, chargeCommand, commandTargets, createWill, creditedVoices, dischargeAdvice, dutyReviewAt, enableSuggestionReasons, endCommand, learnFromVoice, noteCommandOutcome, predictResponse, rememberAdvice, resolveCommand, takeCommand, voicesIn, WILL_DEFAULTS, wakeReviewAt, } from './will/index.js';
47
47
  export const PERSON_DEFAULTS = {
48
48
  /** Body load while idle and awake. */
49
49
  idleLoad: { effort: 0.05, focus: 0.05, mode: 'awake' },
@@ -148,6 +148,8 @@ export function createPerson(spec) {
148
148
  if (family)
149
149
  p.family = family;
150
150
  const on = spec.enable;
151
+ if (on?.suggestionReasons)
152
+ enableSuggestionReasons(p);
151
153
  if (on?.gists)
152
154
  enableGists(p);
153
155
  if (on?.yearbook)
package/dist/types.d.ts CHANGED
@@ -563,6 +563,8 @@ export interface Commitment {
563
563
  label?: string;
564
564
  /** For kind 'abstain': action ids whose completion inside the window breaks it. */
565
565
  violatedBy?: string[];
566
+ /** Firm self abstention: a will-not, lifted for bodily survival like a norm abstention. */
567
+ firm?: true;
566
568
  /** For kind 'abstain': catalogued conditions under which the person may not hold it that day. */
567
569
  exemptWhen?: ('illness' | 'travel')[];
568
570
  /**
@@ -700,6 +702,8 @@ export interface VoiceTrustEvent {
700
702
  * impulse emerges from competing terms, real fatigue costs, habits and precommitments.
701
703
  */
702
704
  export interface WillState {
705
+ /** Opt-in score explanations on suggestion resolutions; absent preserves legacy JSON. */
706
+ suggestionReasons?: true;
703
707
  voices: VoiceRelation[];
704
708
  /**
705
709
  * Precommitments the person made for themselves (e.g. "no work after Isha", "walk instead of eat when bored"):
@@ -885,7 +889,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
885
889
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
886
890
  * `dutyReviewAt`). Person shape unchanged.
887
891
  */
888
- export declare const ENGINE_VERSION = "2.12.0";
892
+ export declare const ENGINE_VERSION = "2.13.0";
889
893
  /** A pregnancy carried by this person (host-driven; see `family/`). */
890
894
  export interface Pregnancy {
891
895
  fatherId: PersonId;
@@ -1374,6 +1378,12 @@ export type SuggestionVerdict = 'assented' | 'complied' | 'deferred' | 'modified
1374
1378
  /** Refusal class for UI: grey = cannot, amber = notNow, red = willNot. */
1375
1379
  export type RefusalKind = 'cannot' | 'notNow' | 'willNot';
1376
1380
  export interface SuggestionResolution {
1381
+ /** Read-only score explanation, only with `enableSuggestionReasons`; margin includes activity inertia. */
1382
+ decidedBy?: {
1383
+ kind: 'inertia' | 'term';
1384
+ source?: string;
1385
+ margin: number;
1386
+ };
1377
1387
  voiceId: EntityId;
1378
1388
  verdict: SuggestionVerdict;
1379
1389
  kind?: RefusalKind;
@@ -1501,6 +1511,8 @@ export interface PersonSpec {
1501
1511
  * `enableCharacterChange` and `enableSkillRetention` right after `createPerson`. Each is off when absent.
1502
1512
  */
1503
1513
  enable?: {
1514
+ /** Read-only suggestion score explanations, as with `enableSuggestionReasons(p)`. */
1515
+ suggestionReasons?: boolean;
1504
1516
  gists?: boolean;
1505
1517
  yearbook?: boolean;
1506
1518
  character?: boolean;
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.12.0';
123
+ export const ENGINE_VERSION = '2.13.0';
@@ -266,6 +266,13 @@ export declare function dominantTerm(c: Considered | undefined): string;
266
266
  * sorted by voice id and bounded by `maxVoices`, so the outcome never depends on the host's argument order.
267
267
  */
268
268
  export declare function voicesIn(single: Suggestion | undefined, list: readonly Suggestion[] | undefined): Suggestion[];
269
+ /**
270
+ * SCOPE (suggestion reasons): opt-in read-only explanations of score-based deferrals and modifications.
271
+ * The gap includes running-activity inertia; source differences aggregate like-labelled terms and tie by
272
+ * source string. This does not change selection, voice wording, counters or RNG, explain vetoes, or claim
273
+ * a score caused a lower-scoring stochastic choice. Legacy resolutions have no additional keys.
274
+ */
275
+ export declare function enableSuggestionReasons(p: Person): void;
269
276
  /** Whether a command names offer `aff` (by affordance id, or by action class when it names no affordance). */
270
277
  export declare const commandTargets: (cmd: Command, aff: Affordance) => boolean;
271
278
  /**
package/dist/will/will.js CHANGED
@@ -36,7 +36,7 @@ import { breakAllows, inBreak } from '../affect/index.js';
36
36
  import { commitmentPressure, isUnderWay, pressureReachedAt } from '../agenda/index.js';
37
37
  import { downedAllows, readCapacities } from '../body/index.js';
38
38
  import { CONSCIENCE_DEFAULTS, normVeto } from '../conscience/index.js';
39
- import { clamp01, cmpStr, decay, dexp, dpow, hasNumbers, isObj, random } from '../core/index.js';
39
+ import { clamp01, cmpStr, decay, dexp, dpow, hasNumbers, isObj, random, round } from '../core/index.js';
40
40
  import { skillLevel } from '../skills/index.js';
41
41
  import { MINUTES_PER_DAY, MINUTES_PER_HOUR } from '../types.js';
42
42
  export const WILL_DEFAULTS = {
@@ -160,6 +160,8 @@ export const WILL_DEFAULTS = {
160
160
  * or `lastCommand` (1.6.0, 1.7.0) are dropped (absent means none). A save the engine wrote is unchanged. @internal
161
161
  */
162
162
  export function sanitizeWill(w) {
163
+ if (w.suggestionReasons !== true)
164
+ delete w.suggestionReasons;
163
165
  if (w.advice !== undefined && !Array.isArray(w.advice))
164
166
  delete w.advice;
165
167
  const cmd = w.command;
@@ -721,6 +723,43 @@ function arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev) {
721
723
  }
722
724
  return false;
723
725
  }
726
+ /**
727
+ * SCOPE (suggestion reasons): opt-in read-only explanations of score-based deferrals and modifications.
728
+ * The gap includes running-activity inertia; source differences aggregate like-labelled terms and tie by
729
+ * source string. This does not change selection, voice wording, counters or RNG, explain vetoes, or claim
730
+ * a score caused a lower-scoring stochastic choice. Legacy resolutions have no additional keys.
731
+ */
732
+ export function enableSuggestionReasons(p) {
733
+ p.will.suggestionReasons = true;
734
+ }
735
+ function scoreReason(selection, live, targetIds) {
736
+ const { winner, ranked, score } = selection;
737
+ if (!winner)
738
+ return undefined;
739
+ // A stochastic draw below the best live score was not decided by the scores.
740
+ if (ranked.some((c) => !c.vetoed && score(c) > score(winner)))
741
+ return undefined;
742
+ // Compare against the closest suggested option by the same adjusted score the winner was chosen on.
743
+ const suggested = [...live].sort((a, b) => score(b) - score(a) || cmpStr(a.affordanceId, b.affordanceId))[0];
744
+ if (!suggested)
745
+ return undefined;
746
+ const gap = score(winner) - score(suggested);
747
+ if (!Number.isFinite(gap) || gap < 0)
748
+ return undefined;
749
+ const margin = round(gap);
750
+ const withoutInertia = [...ranked].sort(byUtilityThenId)[0];
751
+ if (score(winner) > winner.utility && withoutInertia && targetIds.has(withoutInertia.affordanceId))
752
+ return { kind: 'inertia', margin };
753
+ const differences = new Map();
754
+ for (const t of winner.terms)
755
+ differences.set(t.source, (differences.get(t.source) ?? 0) + t.value);
756
+ for (const t of suggested.terms)
757
+ differences.set(t.source, (differences.get(t.source) ?? 0) - t.value);
758
+ const term = [...differences]
759
+ .filter(([, value]) => value > 0)
760
+ .sort((a, b) => b[1] - a[1] || cmpStr(a[0], b[0]))[0];
761
+ return { kind: 'term', ...(term ? { source: term[0] } : {}), margin };
762
+ }
724
763
  /** Judge each voice against the same selected option before resolving competing insistence. */
725
764
  function judgeVoices(p, ctx, suggestions, prepared, selection) {
726
765
  const W = WILL_DEFAULTS;
@@ -829,17 +868,21 @@ function judgeVoices(p, ctx, suggestions, prepared, selection) {
829
868
  }
830
869
  side.pressure = W.pressurePush * clamp01(s.strength);
831
870
  side.refused = 1;
871
+ const decidedBy = p.will.suggestionReasons ? scoreReason(selection, liveSuggested, targetIds) : undefined;
872
+ const explanation = decidedBy ? { decidedBy } : {};
832
873
  const near = winnerAff !== undefined &&
833
874
  suggestedAff !== undefined &&
834
875
  (winnerAff.action === suggestedAff.action ||
835
876
  (mainAim(suggestedAff) !== undefined && mainAim(suggestedAff) === mainAim(winnerAff)));
836
877
  if (near && winnerAff)
837
878
  return out(resolution('modified', reason, {
879
+ ...explanation,
838
880
  kind: 'notNow',
839
881
  insteadAffordanceId: winnerAff.id,
840
882
  counterOffer: { affordanceId: winnerAff.id, label: winnerAff.label },
841
883
  }));
842
884
  return out(resolution('deferred', reason, {
885
+ ...explanation,
843
886
  kind: 'notNow',
844
887
  insteadAffordanceId: winnerAff?.id,
845
888
  counterOffer: winnerAff
@@ -865,6 +908,9 @@ function arbitrateInsistence(p, judged, considered, affById, ev) {
865
908
  forcer = j;
866
909
  }
867
910
  if (forcer?.comply) {
911
+ // The final option is imposed by insistence, not selected by scores.
912
+ for (const j of judged)
913
+ delete j.res.decidedBy;
868
914
  const forced = forcer.comply.target.affordanceId;
869
915
  const forcedAff = affById.get(forced);
870
916
  ev.chosenId = forced;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",