Skip to main content

Welcome to utilitai!

This package is a minimal(ish) implementation of Utility AI in Python. It is designed to complement btreeny, my Behaviour Trees implementation!

There actually aren't many options for utility AI in Python, which surprises me! In general for serious systems you should be looking at C++ (or, maybe, Rust 🦀), but hopefully this library will help on the prototyping journey!

The idea

List the things your agent could do, score each of them against the current state of the world, and do whichever scores highest.

from dataclasses import dataclass

from utilitai import ToConsider, curves


# In order to make decisions, we need context - this can be anything but
# a dataclass is a logical choice
@dataclass
class Context:
    hunger: int
    money: int
    food: int


MAX_HUNGER = 10

# We create a registry object which is generic over the context
things: ToConsider[Context] = ToConsider()


# And then add options to consider
@things.option("go to the shops")
def go_to_the_shops(ctx: Context):
    hunger_level = curves.logistic(ctx.hunger / MAX_HUNGER, midpoint=0.5)
    return hunger_level * curves.is_gt_zero(ctx.money)


# Names can be inferred from function name
@things.option
def eat_food(ctx: Context):
    hunger_level = curves.logistic(ctx.hunger / MAX_HUNGER, midpoint=0.5)
    return hunger_level * curves.is_gt_zero(ctx.food)


# We can also add anoption that has a constant utility
things.constant_option("do nothing", 0.1)

current_context = Context(hunger=5, money=1, food=0)
action = things.consider(current_context)
assert action == "go to the shops"

Scoring functions

A scoring function takes your context and returns a float. That's the whole interface - no subclassing, no DSL, and the decorated function is returned unchanged so you can still call, test, or reuse it elsewhere.

Scores are only ever compared against each other, but keeping them in [0, 1] makes them much easier to reason about. The utilitai.curves module has the usual suspects for shaping a raw value into that range:

curve shape
linear / inverse_linear identity / 1 - val
quadratic / inverse_quadratic accelerates / decelerates quickly
logistic(val, midpoint, steepness) S-shaped, sharp transition around the midpoint
exponential(val, base) strongly favours high inputs
smoothstep ease-in / ease-out, clamped to [0, 1]
is_gt_zero / is_le_zero step functions, handy as on/off multipliers

Because scoring functions are plain Python, curves compose by calling them and combining the results - multiply to say "and", max to say "or", and multiply by a constant to weight an option against its rivals:

@things.option("order a takeaway")
def order_a_takeaway(ctx: Context):
    # Weighted below the other options - it's a treat, not a necessity
    return 0.5 * curves.smoothstep(ctx.hunger) * curves.is_gt_zero(ctx.money)

Choosing between options

  • things.consider(context) returns the name of the highest scoring option. If two options tie, they fall back to priority and then alphabetic on name. Take this into consideration!
  • things.constant_option(name, value) adds an option with a fixed score. This is the usual way to express "if nothing else appeals, do this".
  • things.score(context) returns every option's score as a dict, which is useful in tests and when tuning curves.
  • consider raises ValueError if nothing has been added, and option raises ValueError on a duplicate name.

Each call to consider logs the full set of scores at DEBUG level under the utilitai logger, so you can see why a decision was made:

import logging

logging.basicConfig(level=logging.DEBUG)

Considerations and shared dependencies

In the above, we've repeated ourselves in a couple of places - for instance computing hunger_level. We can see leverage utilitai's "considerations" to define dependencies for our options:

# We create a registry object which is generic over the context
things: ToConsider[Context] = ToConsider()


@things.consideration
def has_money(ctx: Context):
    # Note that by returning `None` we signal to `utilitai` that
    # any dependencies must also be None, and bypass any downstream
    # maths.
    # Feel free to return 0.0 if you don't want this.
    return None if ctx.money == 0 else 1.0


@things.consideration
def has_food(ctx: Context):
    # Note that by returning `None` we signal to `utilitai` that
    # any dependencies must also be None, and bypass any downstream
    # maths.
    # Feel free to return 0.0 if you don't want this.
    return None if ctx.food == 0 else 1.0


@things.consideration
def hunger_level(ctx: Context):
    return curves.logistic(hunger / MAX_HUNGER, midpoint=0.5)


# Note that now the utility of eat_food and go_to_the_shops will be
# identical - making it easy to spot undesirable ties.
# Ties are broken using a priority flag (which defaults to 0)
@things.option("eat_food", priority=1)
def eat_food(ctx: Context, has_food: float, hunger_level: float):
    return hunger_level


@things.option("go to the shops")
def go_to_the_shops(ctx: Context, has_money: float, hunger_level: float):
    return hunger_level


things.constant_option("do nothing", 0.1)

current_context = Context(hunger=5, money=1, food=0)
action = things.consider(current_context)
assert action == "go to the shops"

A slightly bigger example

actions: ToConsider[RobotState] = ToConsider()

# If we have no good choices, do nothing
actions.constant_option("do nothing", 1e-6)


@actions.option("recharge")
def recharge(state: RobotState) -> float:
    # As our battery gets lower, our desire to stop and recharge gets higher
    battery_used = 1 - state.remaining_battery_percentage / 100
    return curves.inverse_quadratic(battery_used)


@actions.option("move to goal")
def move_to_goal(state: RobotState) -> float:
    if is_at_goal(state) or not has_valid_path(state):
        return 0.0
    return 0.9 if can_reach_goal_with_battery(state) else 0.1


@actions.option("do a dance")
def do_a_dance(state: RobotState) -> float:
    # Dancing before we make it to the goal would be silly!
    return 1.0 if is_at_goal(state) else 0.0


while True:
    # Perception
    state = update_state_from_sensors(state)
    # Planning (ish)
    best_action = actions.consider(state)
    # Control - maybe using btreeny!
    control_robot_from_action(best_action, state)

There are also a growing number of examples available for you to peruse.

Metadata

Release files for utilitai 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for utilitai 0.3.1
File Size Uploaded Provenance
utilitai-0.3.1.tar.gz 44.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for utilitai 0.3.1
File Interpreter ABI Platform
utilitai-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 54.3 kB

Release files / utilitai-0.3.1.tar.gz

Download URL utilitai-0.3.1.tar.gz
Size 44.3 kB
Tags Source
SHA-256 checksum
How to use checksums
055ff0581b7271cdd08ced38b0d74cf76df8011b09268c25aa4e08c8d7f5c524
BLAKE2b-256 checksum
How to use checksums
88ee92040103374e6e7ff107a59c66edd3b177d16b2a05363a7a31e877e04385
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 2, 2026.

Transparency log

Release files / utilitai-0.3.1-py3-none-any.whl

Download URL utilitai-0.3.1-py3-none-any.whl
Size 10.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fdbd336424f1a43facfe057b500f6a07463158de0397645d7a515b8d3d5fb475
BLAKE2b-256 checksum
How to use checksums
720922635f997505a70e0eb9b961acf994e680d47d0ac3361279740f1589cf43
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 2, 2026.

Transparency log

Security for utilitai 0.3.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for utilitai-0.3.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 2, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page