Skip to main content

Welcome to utilitai!

This package is a minimal(ish) implementation of Utility AI in Python. It is designed to compliment 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(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(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 add 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
def eat_food(ctx: Context, has_food: float, hunger_level: float, priority=1):
    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=1, 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)

The full version lives in examples/readme_example.py, and examples/eat_sleep_forage.py is a runnable simulation of a hunter-gatherer trying not to starve.

Metadata

Release files for utilitai 0.3.0

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.0
File Size Uploaded Provenance
utilitai-0.3.0.tar.gz 41.0 kB With provenance Details

Built distribution (wheel)

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

Total release size: 50.8 kB

Release files / utilitai-0.3.0.tar.gz

Download URL utilitai-0.3.0.tar.gz
Size 41.0 kB
Tags Source
SHA-256 checksum
How to use checksums
f0af0a592574259738c0adfe2507d3e27deda13bfa82ce558ce1290167f4beb6
BLAKE2b-256 checksum
How to use checksums
781bc6d9a7f31fb739d228df9c3aae315b47aac91a0ca20d423332fd02999b5b
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.0-py3-none-any.whl

Download URL utilitai-0.3.0-py3-none-any.whl
Size 9.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e4eec274736b6c76e7bd35b85edf20251e665a245e4961ba7dcb7332b5198b49
BLAKE2b-256 checksum
How to use checksums
176ed9858880d4ab7eb784029df00f30d3ad346864bb87b415bb3cd16d3572b7
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.0

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.0.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