geppetto

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 5 Imported by: 0

README

geppetto

Dusk in a medieval village. Bram the blacksmith closes his forge. Marta the tavern keeper lights the fire. Aldric the guard ends his round. Rain begins. Bram runs for shelter. Marta opens the door — rain brings customers. Aldric keeps walking: duty outweighs comfort. Nobody wrote this scene. Three NPCs heard the same event and answered three different ways, because different needs and personalities scored the same advertisements the world made.

flowchart LR
    B1[Bram: closing the forge] --> R[Rain]
    M1[Marta: lighting the fire] --> R
    A1[Aldric: ending his round] --> R
    R --> B2[Bram: runs for shelter]
    R --> M2[Marta: opens the door]
    R --> A2[Aldric: keeps the round]

geppetto is the engine behind Aldric's choice: a stateless Utility AI core. The game sends each NPC's state plus the actions the nearby world advertises; geppetto scores them and returns one selected action per NPC.

flowchart LR
    C[Aldric: DUTY strong,<br/>ENERGY fine] --> S[Score each<br/>advertised action]
    W[World offers: take shelter,<br/>keep the round] --> S
    S --> P[Sample from<br/>top candidates]
    P --> O[Keep the round]

Facts

  • Two consumption modes: import the Go module and decide in-process, or run the stateless gRPC subprocess — the client owns the world and sends a fresh snapshot per call; startup handshake is one JSON line on stdout.
  • BatchDecide is the production wire path; Decide is the one-agent path for tuning. The served batch omits preconditions, cost, provider state, and occupants — over the wire, eligibility is reach plus an open slot. In-process, the full model applies, including ConsiderationUpdater.Value functions no wire format can carry.
  • Deterministic within a version: same input and seed, same result; scoring is parallel, reconciliation serial. Across versions the calibration may change — pin the module version for replay.

Use it as a library

decider := geppetto.NewDecider(geppetto.WithProfile(profile))
action := decider.Decide(agent, providers, seed) // same seed, same choice

NewSimulator advances mutable NPC state tick by tick; runnable examples live in example_test.go.

Author your own world

  • The engine ships with no genre and no consideration built in — it reads a profile. HUNGER exists only because a profile says so.
  • A profile is one JSON file declaring the world's considerations (id, range, weight, critical threshold, response curve, updater), the tuning knobs, and the aggregated-event table for NPCs returning from simplified to full simulation.
  • configs/schema/profile.schema.json is the format contract; profiles carry a $schema key, so editors validate as you type and a bad profile fails at startup.
  • The four profiles in configs/ are starting points:
Profile What it illustrates
social-life High temperature (1.2) and preemption margin (1.7): stubborn, emergent behavior.
tactical-stealth Low temperature (0.5): near-optimal competence. Perception-driven THREAT/COVER, event-driven AMMO.
survival-crafting Heavy weights and critical thresholds on HUNGER, THIRST, ENERGY.
open-world-rpg The village above: DUTY against ENERGY and HUNGER, plus perception-driven SECURITY.

Run it

  • make build — produces bin/geppetto
  • make test — go test ./...
  • make dev — hot reload via Air

Documentation

Overview

Package geppetto is the stateless, deterministic Utility AI core used by geppetto. It turns an agent's considerations and the actions advertised by nearby providers into a selected action. The package owns scoring, eligibility, stochastic selection, simulation ticks, preemption, and the deterministic reconciliation of contested batch decisions. It does not discover world entities, open transports, persist NPC state, or apply the actual resource debit performed by a game.

Games normally construct a Decider with WithProfile or WithTuning and pass an explicit seed to Decide, Rank, or DecideBatch. Timed simulation is a separate mutable-agent lifecycle: construct a Simulator with the same options and pass an explicit seed to its Advance method. The separation keeps a point-in-time decision from accidentally owning a game's NPC state, while making both deterministic paths equally available in-process.

The architecture is intentionally an inversion of the usual scripted-NPC relationship. An agent does not contain a table such as "if hungry, find a refrigerator". Objects, places, events, and other actors advertise what they offer through AdvertisedAction values. The agent scores those promises against its current considerations. A new world object therefore adds an advertisement rather than a new branch in every NPC's code: the content surface grows approximately with N+M instead of requiring N*M agent/object integrations.

Considerations and their updaters

A Consideration is a bounded scalar signal such as HUNGER, ENERGY, THREAT, AMMO, COVER, or a game-specific mission value. The engine treats all of these uniformly. The updater, rather than the consideration name, explains how a value changes:

  • LinearDecay subtracts rate*deltaHours, and LinearRegen adds it.
  • EventDriven values are left untouched by a tick; the game changes them through explicit events or action effects.
  • PerceptionDriven, RelationshipDriven, and ContextAggregate values read the corresponding World map, or call their configured Value function. If a map has no entry for the consideration, its previous value is retained rather than silently reset to zero.

Every result is clamped to the consideration's own Min and Max. A missing or invalid range (Max <= Min) makes its response pressure zero, so profiles must declare bounds explicitly. Trait consideration deltas are additive rate adjustments and affect only the linear updater kinds. ApplyGradualDeltas delivers an action's promised deltas at a constant rate over its estimated duration, also clamping after each application; a delta for a consideration the agent does not have is ignored.

Response curves turn a raw value into pressure before scoring. Convex curves use a clamped normalized distance from the maximum raised to an exponent; Linear curves use that normalized distance directly; Step curves switch between Below and Above at Threshold; Logistic curves transition around a Midpoint. A convex exponent of zero inherits the profile default (normally 2), while a configured exponent is an intentional per-consideration override. An unknown curve kind evaluates to zero rather than creating an implicit rule.

DefaultTuning supplies the reference calibration: convex exponent 2, intrinsic-priority weight 5, distance reference 10 world units, selection TopK 3, temperature 1, reconciliation TopK 5, preemption margin 1.5, convention-break probability 0, perception noise 0, a FULL tick of one minute of game time, and a SIMPLIFIED tick of one hour. For parameters that use zero as "not configured", zero falls back to these defaults; it is not a way to disable priority or stochastic selection. In particular, a profile cannot request a true zero temperature through this fallback mechanism. Profiles can still choose genre-specific values: social and open-world profiles use higher temperatures and preemption margins for emergent, stubborn behavior; tactical profiles use lower values and rely more on perception noise; survival profiles give stronger weights to hunger and thirst.

The value zero in action capacity is not the same kind of zero as provider capacity. An AdvertisedAction with Capacity == 0 has no action-level limit; a provider with Capacity == 0 has no available provider slots. The action meaning is deliberate: proto3 encodes an omitted numeric field as zero, and older clients omit action_capacities. Treating that zero as blocking would make every pre-existing action unusable after the field was added. The provider meaning remains blocking. An agent must have a free slot at both levels, and the stricter level wins.

Utility scoring

Candidates first pass binary eligibility gates. The provider must have a free slot, the action must not be saturated, the provider must be within the action's inclusive advertisement radius, all preconditions must hold, the cost must be payable, and no active narrative commitment may contradict the action tags. Failed gates remove a candidate; they do not give it a lower score.

For an eligible action x and agent A, the implementation computes:

benefit = sum(pressure(c) * expectedGain(c, x))
score = (benefit + intrinsicPriority(x)*default priority weight)
        * M_personality * M_context * M_distance
        - weightedCost

The expected gain is saturated per consideration:

expectedGain(c, x) = min(delta(x,c), c.Max-c.Value)

This cap prevents an oversized promise from dominating when an agent has little left to gain. It is intentionally asymmetric: it limits positive gains only. A negative delta remains a real cost to the consideration, because Max-Value is non-negative and cannot clip that negative promise. This is how mixed actions, such as an enjoyable but tiring activity, remain meaningful without a special case.

Each factor has a distinct design job. Pressure makes urgency nonlinear, so a critical need can outweigh a low-value comfort without a hard-coded mode switch. Intrinsic priority lets content authors express a structural reason to prefer an action, but it is added before the other multipliers and thus still obeys distance. Personality multiplies tag modifiers and the domain preference, allowing traits to amplify or suppress a whole promise without making an action attractive when it helps none of the agent's considerations. Distance uses the hyperbolic multiplier 1/(1+d/default distance reference), making a nearby modest opportunity compete with a distant ideal one. Cost is subtracted last so positive personality, context, or distance modifiers cannot make a resource bill disappear.

Context has an important, deliberate asymmetry in this implementation. For every active Context, the tag multiplier is applied twice:

M_context = product(contextTagMultiplier * contextTagMultiplier)

Thus a nominal context multiplier of 2 for an action tag contributes 4 to the score. Applying the multiplier only once lets the effect dissolve in the later softmax and does not produce the intended doubling of observable action frequency. Do not simplify this to a single factor without recalibrating the behavior and its acceptance expectations.

weightedCost is also more specific than the name might suggest. The real implementation is:

sum(cost / max(1, available/cost))

For an eligible positive cost, that is cost^2/available. Doubling the cost therefore quadruples its penalty, while halving the available stock doubles the penalty for the same cost. Eligibility checks affordability first; the score then models scarcity. The penalty is not multiplied by the positive factors above.

Selection and deliberate imperfection

Candidates are stable-sorted by descending utility. SelectAction keeps the configured SelectionTopK (default 3) and samples with a numerically stable softmax. The maximum utility is subtracted before exponentiation, which preserves the distribution while avoiding overflow. A non-positive SelectionTemperature falls back to the default 1.0; temperature zero is therefore not an accidental way to request argmax. Lower temperatures make choices more competent and predictable, while higher temperatures flatten the differences. A TopK of one degenerates to argmax.

Imperfection is a design principle, not an implementation defect. Always taking the maximum makes NPCs predictable, exploitable, and narratively sterile. The stochastic pick is retained as the first preference in a batch so that contention resolution does not silently turn the system back into argmax. Commitment to a current action, limited advertisement radius, and personality or context biases provide further, tunable sources of behavior that is imperfect but still legible. PerceptionNoise adds zero-mean Gaussian noise with the configured standard deviation to each perception-driven consideration in a temporary scoring copy; the observed world state remains unchanged. ConventionBreakProbability defines the normal convention as the highest-utility eligible action. When it succeeds, the normal softmax pick is replaced by a uniformly selected eligible action with strictly lower utility, when one exists. The aim is variety that an observer can explain, not minimum error.

Ticks, preemption, and simulation detail

Advance is the in-process entry point. Its TickRequest carries the absolute simulation hour, world, desired simulation level, and optional simplified event; TickResult reports the effective level and simulated duration. The integrating simulation chooses the target level because only it knows visibility and importance. FullTickHours and SimplifiedTickHours determine the duration returned for their respective levels, falling back to the named engine defaults when unset.

Advance first expires commitments whose ExpiresAt is at or before NowHours. A simplified advance records its optional event at NowHours. Returning to Full applies the profile's aggregated-event effects before the detailed interval. Tick then updates considerations, applies gradual deltas to the current action, and clears a completed action. An idle agent dequeues the next FIFO ActionQueue entry, or makes an autonomous selection when no work is queued; a continuing action is then considered for preemption. A newly started action therefore does not receive its first gradual delta until the following interval. The engine owns queue progression through EnqueueAction, while the integrating game remains responsible for enqueuing intent and debiting costs. Preemption abandons the current action rather than requeueing partial work; pending FIFO entries remain available after the replacement action finishes.

Preemption requires at least one consideration below its configured CriticalThreshold. A player-queued action is protected unless some value reaches imminent collapse, read from that consideration's own CriticalThreshold; a consideration without one falls back to the engine's default imminent-collapse threshold. The replacement is selected through the same stochastic pipeline, and it must strictly exceed preemptionMargin times the current action's frozen ContinuationUtility. Equality does not preempt. This frozen utility is the snapshot captured when the action was selected, so an action that was very valuable when started can be deliberately difficult to interrupt.

ApplyAggregatedEvent records coarse events while an agent is simplified; TransitionToFull applies the profile's AggregatedEventEffects table to the events that occurred by the target hour, clamping each resulting consideration value, then discards the event log and returns the agent to Full. The table is data: the engine knows neither event nor consideration names, and a profile without it reconstructs nothing. The mechanism is intentionally small and stateless: the game remains responsible for deciding which distant events to aggregate.

Batch contention and determinism

RankedPreferences performs the normal stochastic selection first, then retains descending-utility fallbacks up to ReconciliationTopK (default 5). ResolveContention consumes those lists in rounds. Every open agent claims its current candidate; claims are grouped by provider, and each provider's contenders are ordered by increasing physical distance, then agent ID, then input index. Provider slots are Capacity minus the reported Occupants count. Action slots are tracked per provider instance and start at action Capacity minus action Occupancy; zero action capacity means unlimited. A candidate is granted only if both levels have a slot.

Proximity, not utility, decides a dispute. This is a physical rule: the agent that can reach the resource first gets it. Hunger does not accelerate anyone, so a hungry distant agent must not teleport priority away from a nearby agent merely because its score is higher. A loser advances through its own preference list. That can displace a provisional winner elsewhere, so rounds continue until one complete round has no displacement. An agent that exhausts its list receives no ActionInstance. Because every cursor only moves forward through a finite retained list, termination is guaranteed.

Stability

Within a single version of this module, the same input and the same seed always produce the same result. That guarantee is tested, and it is the whole guarantee. Across versions there is no determinism commitment: the scoring formula and its calibration may change in any minor release, and the same seed may then select a different action. A game that needs deterministic replay or save compatibility must pin the module version.

The scoring phase is independent and can run in parallel; the reconciliation phase is a small serial reduction over bounded lists. Map iteration order and worker completion order cannot change the result: each agent appears in one claim list per round, and the within-provider order is explicit. With the same input and random seed, the selected preferences and final assignments are deterministic. No contention state survives the call.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyAggregatedEvent

func ApplyAggregatedEvent(agent *Agent, event AggregatedEvent)

ApplyAggregatedEvent records an event produced while an agent uses simplified LOD.

func ApplyGradualDeltas

func ApplyGradualDeltas(agent *Agent, instance *ActionInstance, deltaHours float64)

ApplyGradualDeltas applies an action's promised deltas at a constant rate.

func CheckPreemption

func CheckPreemption(agent *Agent, providers []AffordanceProvider, tuning Tuning, rng *rand.Rand) bool

CheckPreemption replaces the current action when a critical consideration makes the best available action valuable enough to clear the configured margin.

func EnqueueAction

func EnqueueAction(agent *Agent, action ActionInstance)

EnqueueAction appends an integrating game's explicit action request. Queued actions are FIFO and protected from ordinary preemption after they start.

func ScoreAction

func ScoreAction(agent Agent, provider AffordanceProvider, action AdvertisedAction, tuning Tuning) float64

ScoreAction scores an advertised promise. Personality, context, and distance scale benefits; costs remain penalties so a cheap action cannot become expensive through a positive modifier.

func Tick

func Tick(agent *Agent, providers []AffordanceProvider, world World, deltaHours float64, tuning Tuning, rng *rand.Rand)

Tick advances one detailed interval after Advance has chosen Full.

func TransitionToFull

func TransitionToFull(agent *Agent, hour float64, effects []AggregatedEventEffect)

TransitionToFull reconstructs a plausible detailed state from accumulated events. The effects table, declared by the profile, maps each aggregated event kind to the consideration deltas it applies — the engine knows neither event nor consideration names. Events after hour and undeclared kinds are ignored; a nil table reconstructs nothing.

func UpdateConsiderations

func UpdateConsiderations(agent *Agent, world World, deltaHours float64)

UpdateConsiderations advances every updater, applies trait rate adjustments, and clamps each result to its configured bounds.

Types

type ActionInstance

type ActionInstance struct {
	Action              AdvertisedAction
	ProviderID          string
	Elapsed             float64
	ContinuationUtility float64
	PlayerQueued        bool
}

ActionInstance records a selected action and its progress for preemption.

func ResolveContention

func ResolveContention(agents []Agent, preferences [][]Candidate) []*ActionInstance

ResolveContention distributes contested slots to the nearest agents. preferences[i] is agent i's ordered candidate list, as produced by RankedPreferences. It returns one ActionInstance per agent, nil when the agent exhausted its candidates.

Slots are enforced at TWO levels: the provider's (Capacity minus the occupants reported by the client) and the action's own (Capacity minus Occupancy; action capacity 0 means unlimited — see the warning on AdvertisedAction.Capacity). An agent wins its current candidate only with a free slot at both levels; the stricter level wins.

The loop terminates because a displaced agent's cursor only moves forward through its own finite list: each agent is displaced from a given candidate at most once, so total displacements are bounded by the sum of the list lengths.

func SelectAction

func SelectAction(agent Agent, providers []AffordanceProvider, tuning Tuning, rng *rand.Rand) *ActionInstance

SelectAction selects one of the highest-scoring candidates with softmax.

type AdvertisedAction

type AdvertisedAction struct {
	ActionID            string             `json:"action_id"`
	Deltas              map[string]float64 `json:"deltas"`
	EstimatedDuration   float64            `json:"estimated_duration"`
	Tags                []string           `json:"tags"`
	Domain              string             `json:"domain"`
	Preconditions       []Precondition     `json:"preconditions"`
	IntrinsicPriority   float64            `json:"intrinsic_priority"`
	AdvertisementRadius float64            `json:"advertisement_radius"`
	Cost                map[string]float64 `json:"cost"`
	// Capacity caps simultaneous agents on THIS action. WARNING: unlike
	// AffordanceProvider.Capacity, zero means UNLIMITED — proto3 encodes an
	// absent numeric field as 0 and every pre-existing client sends 0, so 0
	// must read as "no own limit" or every existing action would brick. An
	// agent joins only with a free slot at both levels; the stricter wins.
	Capacity int `json:"capacity"`
	// Occupancy is how many agents the client reports as already executing
	// this action at tick start. It consumes action slots before the batch
	// is reconciled.
	Occupancy int `json:"occupancy"`
}

AdvertisedAction is a provider's declarative promise of consideration deltas.

type AffordanceProvider

type AffordanceProvider struct {
	ID                string             `json:"id"`
	Position          Position           `json:"position"`
	AdvertisedActions []AdvertisedAction `json:"advertised_actions"`
	Capacity          int                `json:"capacity"`
	Occupants         []string           `json:"occupants"`
	State             string             `json:"state"`
}

AffordanceProvider advertises actions at a world position with limited capacity.

type Agent

type Agent struct {
	ID             string
	Considerations map[string]Consideration
	Personality    Personality
	CurrentAction  *ActionInstance
	// ActionQueue holds FIFO actions supplied through EnqueueAction. The engine
	// starts its head whenever the agent becomes idle.
	ActionQueue          []ActionInstance
	Position             Position
	Capabilities         map[string]bool
	ActiveContexts       []Context
	SimulationLevel      SimulationLevel
	NarrativeCommitments []NarrativeCommitment
	Resources            map[string]float64
	AggregatedEvents     []AggregatedEvent
}

Agent contains all state the engine needs to update and score one NPC.

type AggregatedEvent

type AggregatedEvent struct {
	Kind   string
	AtHour float64
}

AggregatedEvent records a coarse simulation event for a simplified agent.

type AggregatedEventEffect

type AggregatedEventEffect struct {
	// Kind matches AggregatedEvent.Kind.
	Kind string `json:"kind"`
	// Deltas are added to the matching considerations, clamped to their bounds.
	Deltas map[string]float64 `json:"deltas"`
}

AggregatedEventEffect declares how one kind of aggregated event rebuilds consideration state when an agent returns to full simulation detail. The profile owns the table; the engine applies it without knowing event or consideration names.

type Candidate

type Candidate struct {
	Action   AdvertisedAction
	Provider AffordanceProvider
	Utility  float64
}

Candidate couples an eligible advertised action with its scored utility.

func Candidates

func Candidates(agent Agent, providers []AffordanceProvider, tuning Tuning) []Candidate

Candidates returns all eligible actions in descending utility order.

func RankedPreferences

func RankedPreferences(agent Agent, providers []AffordanceProvider, tuning Tuning, rng *rand.Rand) []Candidate

RankedPreferences returns the agent's candidates ordered for contention resolution: the stochastic softmax pick first (identical to what SelectAction would choose with the same rng), then the remaining candidates in descending utility as fallbacks, capped at the configured limit.

type Consideration

type Consideration struct {
	ID                string               `json:"id"`
	Value             float64              `json:"value"`
	Min               float64              `json:"min"`
	Max               float64              `json:"max"`
	Updater           ConsiderationUpdater `json:"updater"`
	BaseWeight        float64              `json:"base_weight"`
	CriticalThreshold float64              `json:"critical_threshold"`
	ResponseCurve     ResponseCurve        `json:"response_curve"`
}

Consideration is a normalized NPC signal, its bounds, and its scoring rule.

type ConsiderationUpdater

type ConsiderationUpdater struct {
	Kind UpdaterKind `json:"kind"`
	Rate float64     `json:"rate,omitempty"`
	// Value is an optional in-process extension point for the world-driven
	// kinds (PerceptionDriven, RelationshipDriven, ContextAggregate). When
	// set, a tick calls it instead of reading the corresponding World map,
	// so a game can derive the consideration from state the JSON profile
	// format cannot express — line of sight, faction standing, inventory
	// contents — by closing over its own world representation. The function
	// must be a pure, deterministic function of the supplied agent and
	// world: it runs inside the simulation tick, and determinism guarantees
	// assume it. Value is ignored by the linear and event-driven kinds and
	// cannot be loaded from a profile document.
	Value func(Agent, World) float64 `json:"-"`
}

ConsiderationUpdater defines the dynamics applied to a Consideration.

type Context

type Context struct {
	Modifiers map[string]float64 `json:"modifiers"`
	Duration  float64            `json:"duration"`
}

Context temporarily biases actions carrying matching tags.

type Decider

type Decider struct {
	// contains filtered or unexported fields
}

Decider selects actions from a supplied agent and provider snapshot. It has no mutable simulation state; pass an explicit seed to every operation so independent decisions remain reproducible.

func NewDecider

func NewDecider(options ...Option) Decider

NewDecider constructs a decision API. WithProfile supplies its tuning, and WithTuning is useful when a caller owns model data without a JSON profile.

func (Decider) Decide

func (decider Decider) Decide(agent Agent, providers []AffordanceProvider, seed uint64) *ActionInstance

Decide selects an action for one agent from the supplied provider snapshot. The seed owns the complete pseudo-random stream for this decision.

Example

A hungry villager scores the advertisements around it and the kitchen wins. The same seed always reproduces the same choice.

package main

import (
	"fmt"

	"github.com/Otoru/geppetto"
)

// villageProfile is the smallest profile that still shows pressure, decay,
// and competing advertisements.
func villageProfile() geppetto.Profile {
	consideration := func(id string, value float64) geppetto.Consideration {
		return geppetto.Consideration{
			ID: id, Value: value, Min: -100, Max: 100, BaseWeight: 1,
			CriticalThreshold: -50,
			Updater:           geppetto.ConsiderationUpdater{Kind: geppetto.LinearDecay, Rate: 5},
			ResponseCurve:     geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2},
		}
	}
	return geppetto.Profile{
		Name: "village",
		Considerations: []geppetto.Consideration{
			consideration("HUNGER", 0),
			consideration("ENERGY", 0),
		},
		Tuning: geppetto.DefaultTuning(),
	}
}

func main() {
	profile := villageProfile()
	decider := geppetto.NewDecider(geppetto.WithProfile(profile))

	villager := geppetto.Agent{
		ID: "bram",
		Considerations: map[string]geppetto.Consideration{
			"HUNGER": {ID: "HUNGER", Value: -80, Min: -100, Max: 100, BaseWeight: 1, ResponseCurve: geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2}},
			"ENERGY": {ID: "ENERGY", Value: 50, Min: -100, Max: 100, BaseWeight: 1, ResponseCurve: geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2}},
		},
	}
	providers := []geppetto.AffordanceProvider{
		{
			ID: "kitchen", Capacity: 2,
			AdvertisedActions: []geppetto.AdvertisedAction{{
				ActionID: "eat", Deltas: map[string]float64{"HUNGER": 40},
				EstimatedDuration: 1, AdvertisementRadius: 50,
			}},
		},
		{
			ID: "bench", Capacity: 1,
			AdvertisedActions: []geppetto.AdvertisedAction{{
				ActionID: "rest", Deltas: map[string]float64{"ENERGY": 30},
				EstimatedDuration: 1, AdvertisementRadius: 50,
			}},
		},
	}

	chosen := decider.Decide(villager, providers, 42)
	fmt.Printf("bram chooses %s at %s\n", chosen.Action.ActionID, chosen.ProviderID)

}
Output:
bram chooses eat at kitchen

func (Decider) DecideBatch

func (decider Decider) DecideBatch(agents []Agent, providers []AffordanceProvider, seed uint64) []*ActionInstance

DecideBatch ranks each agent with seed plus its stable slice index, then reconciles contested capacity. It is a convenience for callers that do not need their own parallel ranking phase.

func (Decider) Rank

func (decider Decider) Rank(agent Agent, providers []AffordanceProvider, seed uint64) []Candidate

Rank produces one agent's ordered, contention-ready preferences. The seed owns the complete pseudo-random stream for this ranking.

func (Decider) Reconcile

func (decider Decider) Reconcile(agents []Agent, preferences [][]Candidate) []*ActionInstance

Reconcile assigns contested provider and action slots from rankings returned by Rank. The operation is deterministic and consumes no random values.

type NarrativeCommitment

type NarrativeCommitment struct {
	ID                string          `json:"id"`
	ContradictoryTags map[string]bool `json:"contradictory_tags"`
	ExpiresAt         float64         `json:"expires_at"`
}

NarrativeCommitment prevents autonomous actions with contradictory tags. ExpiresAt is an absolute simulation hour; zero means the commitment does not expire.

type Option

type Option func(*options)

Option configures a Decider or Simulator. Options are immutable values and may be reused when constructing multiple SDK objects.

func WithProfile

func WithProfile(profile Profile) Option

WithProfile supplies the profile used for tuning and aggregated-event reconstruction. WithTuning overrides only the profile's tuning values.

func WithTuning

func WithTuning(tuning Tuning) Option

WithTuning supplies tuning without requiring a profile. When combined with WithProfile, this option overrides Profile.Tuning regardless of option order.

type Personality

type Personality struct {
	Preferences map[string]float64 `json:"preferences"`
	Traits      []Trait            `json:"traits"`
}

Personality collects an agent's domain preferences and traits.

type Position

type Position struct{ X, Y, Z float64 }

Position is a three-dimensional point in world units.

func (Position) Distance

func (p Position) Distance(to Position) float64

Distance returns the Euclidean distance from p to to.

type Precondition

type Precondition struct {
	Capability string  `json:"capability,omitempty"`
	Resource   string  `json:"resource,omitempty"`
	Minimum    float64 `json:"minimum,omitempty"`
	// RequiredState must match the AffordanceProvider state when it is non-empty.
	RequiredState string `json:"required_state,omitempty"`
}

Precondition is an agent capability/resource requirement or a provider-state requirement for an AdvertisedAction.

type Profile

type Profile struct {
	Name                   string                  `json:"name"`
	Considerations         []Consideration         `json:"considerations"`
	Tuning                 Tuning                  `json:"tuning"`
	AggregatedEventEffects []AggregatedEventEffect `json:"aggregated_event_effects,omitempty"`
}

Profile defines the consideration order and tuning shared by a decision batch or simulation. AggregatedEventEffects rebuild detailed state after a simplified simulation interval.

type ResponseCurve

type ResponseCurve struct {
	Kind ResponseCurveKind `json:"kind"`
	// Exponent overrides the profile default for this convex consideration.
	Exponent  float64 `json:"exponent,omitempty"`
	Threshold float64 `json:"threshold,omitempty"`
	Below     float64 `json:"below,omitempty"`
	Above     float64 `json:"above,omitempty"`
	Slope     float64 `json:"slope,omitempty"`
	Midpoint  float64 `json:"midpoint,omitempty"`
}

ResponseCurve configures the response-curve parameters for a Consideration.

func (ResponseCurve) Evaluate

func (r ResponseCurve) Evaluate(value, minimum, maximum float64) float64

Evaluate converts value into the normalized pressure defined by r. A convex curve without an explicit exponent resolves through the same default path as the scoring pipeline, with an empty tuning.

type ResponseCurveKind

type ResponseCurveKind string

ResponseCurveKind identifies the function used to turn a consideration value into a normalized pressure.

const (
	// Convex increases pressure rapidly as a consideration approaches its minimum.
	Convex ResponseCurveKind = "convex"
	// Linear changes pressure proportionally to the consideration value.
	Linear ResponseCurveKind = "linear"
	// Step switches pressure at the curve threshold.
	Step ResponseCurveKind = "step"
	// Logistic transitions pressure smoothly around the curve midpoint.
	Logistic ResponseCurveKind = "logistic"
)

type SimulationLevel

type SimulationLevel string

SimulationLevel identifies whether the agent receives detailed or aggregated updates.

const (
	// Full runs the detailed per-tick decision loop.
	Full SimulationLevel = "FULL"
	// Simplified represents an agent with aggregated, low-detail events.
	Simplified SimulationLevel = "SIMPLIFIED"
)

type Simulator

type Simulator struct {
	// contains filtered or unexported fields
}

Simulator advances mutable agent state through the timed simulation loop. It retains the profile's aggregation table but no agent state or random state, so callers can safely share it across independent agents.

func NewSimulator

func NewSimulator(options ...Option) Simulator

NewSimulator constructs a timed simulation API. WithProfile provides both event reconstruction data and tuning; WithTuning overrides its tuning.

func (Simulator) Advance

func (simulator Simulator) Advance(agent *Agent, providers []AffordanceProvider, request TickRequest, seed uint64) TickResult

Advance advances agent through one complete timed simulation step. The seed owns the random stream for this step, including any selection or preemption.

Example

One hour per tick: hunger and energy decay, the agent eats, then rests, then eats again as pressures shift. Every tick passes its own seed.

package main

import (
	"fmt"

	"github.com/Otoru/geppetto"
)

// villageProfile is the smallest profile that still shows pressure, decay,
// and competing advertisements.
func villageProfile() geppetto.Profile {
	consideration := func(id string, value float64) geppetto.Consideration {
		return geppetto.Consideration{
			ID: id, Value: value, Min: -100, Max: 100, BaseWeight: 1,
			CriticalThreshold: -50,
			Updater:           geppetto.ConsiderationUpdater{Kind: geppetto.LinearDecay, Rate: 5},
			ResponseCurve:     geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2},
		}
	}
	return geppetto.Profile{
		Name: "village",
		Considerations: []geppetto.Consideration{
			consideration("HUNGER", 0),
			consideration("ENERGY", 0),
		},
		Tuning: geppetto.DefaultTuning(),
	}
}

func main() {
	profile := villageProfile()
	profile.Tuning.FullTickHours = 1
	simulator := geppetto.NewSimulator(geppetto.WithProfile(profile))

	villager := geppetto.Agent{
		ID: "marta",
		Considerations: map[string]geppetto.Consideration{
			"HUNGER": {ID: "HUNGER", Value: -70, Min: -100, Max: 100, BaseWeight: 1, CriticalThreshold: -50, Updater: geppetto.ConsiderationUpdater{Kind: geppetto.LinearDecay, Rate: 5}, ResponseCurve: geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2}},
			"ENERGY": {ID: "ENERGY", Value: -40, Min: -100, Max: 100, BaseWeight: 1, CriticalThreshold: -50, Updater: geppetto.ConsiderationUpdater{Kind: geppetto.LinearDecay, Rate: 5}, ResponseCurve: geppetto.ResponseCurve{Kind: geppetto.Convex, Exponent: 2}},
		},
	}
	providers := []geppetto.AffordanceProvider{
		{
			ID: "kitchen", Capacity: 1,
			AdvertisedActions: []geppetto.AdvertisedAction{{
				ActionID: "eat", Deltas: map[string]float64{"HUNGER": 70},
				EstimatedDuration: 1, AdvertisementRadius: 50,
			}},
		},
		{
			ID: "bench", Capacity: 1,
			AdvertisedActions: []geppetto.AdvertisedAction{{
				ActionID: "rest", Deltas: map[string]float64{"ENERGY": 60},
				EstimatedDuration: 1, AdvertisementRadius: 50,
			}},
		},
	}

	for tick := 1; tick <= 3; tick++ {
		simulator.Advance(&villager, providers, geppetto.TickRequest{NowHours: float64(tick)}, 42+uint64(tick))
		fmt.Printf("tick %d: HUNGER=%.0f ENERGY=%.0f action=%s\n",
			tick,
			villager.Considerations["HUNGER"].Value,
			villager.Considerations["ENERGY"].Value,
			villager.CurrentAction.Action.ActionID)
	}

}
Output:
tick 1: HUNGER=-75 ENERGY=-45 action=eat
tick 2: HUNGER=-10 ENERGY=-50 action=rest
tick 3: HUNGER=-15 ENERGY=5 action=eat

type TickRequest

type TickRequest struct {
	World           World
	NowHours        float64
	TargetLevel     SimulationLevel
	AggregatedEvent *AggregatedEvent
}

TickRequest describes one in-process Advance call. NowHours is the absolute simulation hour at which expiry and aggregate-event timestamps are evaluated. TargetLevel is selected by the integrating simulation; an empty value keeps the agent's current level (or Full for a new agent). AggregatedEvent is recorded only while the target level is Simplified.

type TickResult

type TickResult struct {
	SimulationLevel SimulationLevel
	ElapsedHours    float64
}

TickResult reports the effective level and simulated duration of one Advance call. The caller schedules its next advance after ElapsedHours.

func Advance

func Advance(agent *Agent, profile Profile, providers []AffordanceProvider, request TickRequest, rng *rand.Rand) TickResult

Advance runs one complete simulation step. It first expires commitments so expired restrictions cannot block this step. A simplified step records its optional coarse event and returns its aggregate cadence. A transition back to Full reconstructs accumulated events before the detailed tick updates considerations, applies the current action, then either starts queued work or selects a new action for an idle agent, or evaluates preemption for a continuing action. This order lets the decision see the state produced by the elapsed interval while a newly started action begins contributing on the following interval.

type Trait

type Trait struct {
	ID                 string             `json:"id"`
	Modifiers          map[string]float64 `json:"modifiers"`
	ConsiderationDelta map[string]float64 `json:"consideration_delta"`
}

Trait changes action-tag multipliers and consideration updater rates.

type Tuning

type Tuning struct {
	// ResponseCurveExponent is the profile default for convex curves without an override.
	ResponseCurveExponent float64 `json:"response_curve_exponent"`
	WPriority             float64 `json:"W_PRIORITY"`
	DistanceReference     float64 `json:"DISTANCE_REFERENCE"`
	SelectionTopK         int     `json:"SELECTION_TOP_K"`
	SelectionTemperature  float64 `json:"SELECTION_TEMPERATURE"`
	// ReconciliationTopK caps how many ordered candidates each agent retains
	// for contention resolution: the stochastic first pick plus
	// utility-ordered fallbacks.
	ReconciliationTopK int     `json:"RECONCILIATION_TOP_K"`
	PreemptionMargin   float64 `json:"preemption_margin"`
	// ConventionBreakProbability replaces the normal choice with a strictly
	// lower-utility eligible action when its probability succeeds.
	ConventionBreakProbability float64 `json:"convention_break_probability"`
	// PerceptionNoise is the standard deviation of Gaussian noise applied to
	// perception-driven considerations while scoring a decision.
	PerceptionNoise float64 `json:"perception_noise"`
	// FullTickHours is the simulated duration of one detailed Advance call.
	FullTickHours float64 `json:"full_tick_hours"`
	// SimplifiedTickHours is the simulated duration of one aggregated Advance call.
	SimplifiedTickHours float64 `json:"simplified_tick_hours"`
}

Tuning collects the profile-level parameters that control engine behavior.

func DefaultTuning

func DefaultTuning() Tuning

DefaultTuning returns the specification's default tuning values.

func (Tuning) WithDefaults added in v0.2.0

func (tuning Tuning) WithDefaults() Tuning

WithDefaults resolves omitted tuning values to the reference calibration. Profile loading calls it once; SDK construction also applies it for callers that construct a Profile or Tuning directly in Go.

type UpdaterKind

type UpdaterKind string

UpdaterKind identifies how a Consideration changes between decisions.

const (
	// LinearDecay subtracts Rate on every update.
	LinearDecay UpdaterKind = "linear_decay"
	// LinearRegen adds Rate on every update.
	LinearRegen UpdaterKind = "linear_regen"
	// EventDriven changes only when an explicit event mutates it.
	EventDriven UpdaterKind = "event_driven"
	// PerceptionDriven reads a value from the perceived world.
	PerceptionDriven UpdaterKind = "perception_driven"
	// RelationshipDriven derives its value from a relationship.
	RelationshipDriven UpdaterKind = "relationship_driven"
	// ContextAggregate derives its value from environmental context.
	ContextAggregate UpdaterKind = "context_aggregate"
)

type World

type World struct {
	Perception    map[string]float64
	Relationships map[string]float64
	ContextValues map[string]float64
}

World supplies external values used by consideration updaters. Perception, Relationships, and ContextValues feed their corresponding updater kinds. It is input to a tick, not state owned by the engine.

Directories

Path Synopsis
cmd
geppetto command
Package main is the geppetto subprocess entry point.
Package main is the geppetto subprocess entry point.
internal
config
Package config defines the runtime settings used to wire a geppetto process.
Package config defines the runtime settings used to wire a geppetto process.
logging
Package logging builds the process-wide zap logger for geppetto.
Package logging builds the process-wide zap logger for geppetto.
service
Package service adapts the protobuf wire contract to the public geppetto SDK.
Package service adapts the protobuf wire contract to the public geppetto SDK.
transport
Package transport creates the process listener selected by the command-line configuration.
Package transport creates the process listener selected by the command-line configuration.
Package profile loads and validates geppetto JSON profiles.
Package profile loads and validates geppetto JSON profiles.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL