Skip to main content

londec — Decision Maker

Evaluate tree-structured, JSON-serializable conditions against an ordered history of typed events. Each condition resolves to a Decision(satisfied, when)satisfied an explicit bool, when an optional timestamp — enabling eligibility checks, scheduling triggers, and automation rules that are stored as data, not code.

londec was created by Longenesis to solve complex eligibility rules in patient journeys — deciding, from a participant's accumulating history of activities and submissions, whether and when they qualify for the next step. The library itself is domain-agnostic and works with any ordered event history, not just healthcare data.

flowchart TD
    subgraph events["Events  (list[dict], oldest → newest)"]
        E1["event_type: signup · Jan 5"]
        E2["event_type: purchase · Jan 10"]
        E3["event_type: purchase · Jan 18"]
    end

    EH["event_happened<br/>event_type: purchase<br/>→ Decision(True, Jan 18)"]
    DL["delay · days: 14<br/>event_type: purchase<br/>→ Decision(True, Feb 1)"]
    AND["AND<br/>max(Jan 18, Feb 1)"]
    R(["Decision(True, Feb 1)"])

    events -.->|evaluate| EH
    events -.->|evaluate| DL
    EH --> AND
    DL --> AND
    AND --> R

The problems londec solves

Rules stored in a database, not compiled into code

Eligibility and scheduling logic tend to end up hardcoded: changing who qualifies for a feature requires a code change and a deploy. londec conditions are plain Python dicts — JSON-serializable, storable in a database, editable through a UI, and passed to londec.decide at runtime. The logic lives in data, not in a release.

# Stored in DB, loaded at runtime — no deploy needed to update the rule
condition = {
    "type": "AND",
    "list": [
        {"type": "event_happened", "activity_id": "onboarding_complete"},
        {"type": "event_happened_fewer_than", "activity_id": "invoice_sent", "x": 3},
        {"type": "available_on_date_range", "start_date": "2026-01-01", "end_date": "2026-06-30", "timezone_offset": 120},
    ]
}

result = londec.decide(condition, events=user_events, field_map=FIELD_MAP)

This pattern fits naturally wherever rules vary per tenant, per plan, or per campaign — and need to be updated without touching application code.

When — not just whether

A boolean answer is often not enough. If a rule is not yet satisfied, a scheduler needs to know when to check again. londec returns a Decision(satisfied, when): satisfied is always an explicit, directly-computed bool — never inferred by comparing when to now after the fact — and when is an optional timestamp for lookahead/display:

result = londec.decide(
    {"type": "delay", "activity_id": "trial_started", "days": 14},
    events=user_events,
    field_map=FIELD_MAP,
)
# Decision(False, None)        → trial never started; nothing to schedule
# Decision(False, datetime(...)) → trial started, but the 14-day window hasn't opened yet;
#                                    `when` is the date it will
# Decision(True, datetime(...))  → the window is open, since `when`

This makes londec useful not just for access control ("is this user eligible right now?") but for proactive scheduling ("when should this rule next be evaluated or triggered?").

Use cases that benefit from this:

  • Drip campaigns — send a follow-up exactly N days after a user completed a step
  • Trial-to-paid conversion — trigger an upsell prompt the moment a trial window closes
  • Loyalty rewards — unlock a reward tier as soon as a user's Nth qualifying action is recorded
  • Time-gated content — open the next module precisely when a prerequisite period has elapsed
  • Deployment pipelines — schedule a production deploy for the earliest moment all gates are clear

Composable conditions with datetime propagation

Conditions compose into AND/OR trees. satisfied is always computed via genuine all()/any() over each child's own satisfied flag; when is aggregated separately, only among children that have one — so the result remains useful for scheduling without ever inferring satisfaction from a date comparison:

  • AND (MAX_AND) — satisfied when the last prerequisite is met; reports the latest when
  • MIN_AND — same satisfaction as AND; reports the earliest when (useful to know when the first prerequisite was met)
  • OR (MIN_OR) — satisfied as soon as any branch is; reports the earliest when
  • MAX_OR — same satisfaction as OR; reports the latest when across satisfied branches
# "Eligible for a loyalty reward after placing 3 orders AND waiting 30 days since the first"
condition = {
    "type": "AND",
    "list": [
        {"type": "event_happened_at_least", "activity_id": "order_placed", "x": 3},
        {"type": "delay", "activity_id": "order_placed", "days": 30},
    ]
}

Nesting is unrestricted — OR inside AND, AND inside OR — allowing arbitrarily complex rules without writing new evaluator code.

Checking data values across the event history

payload_match checks the value of any key in the event dict against an expected answer. It operates on the most recent event by default, or at a specific position in the event history via seq_num.

# "The most recent assessment event has a risk_score of at least 7"
{"type": "payload_match", "activity_id": "assessment", "key": "risk_score", "answer": "7", "sub_type": "gte"}

# "The second-most-recent check-in had an engagement score below 3"
{"type": "payload_match", "activity_id": "check_in", "key": "engagement", "answer": "3", "sub_type": "lt", "seq_num": 1}

# "The most recent event of any type flagged the account for review"
# (no activity_id → checks across all event types)
{"type": "payload_match", "key": "review_flag", "answer": "true"}

seq_num counts from the most recent: 0 (default) is the latest, 1 is the second latest, and so on. Revoked events are excluded before indexing.

Domain-agnostic — works with any flat event structure

londec has no opinion about your data model. Events are plain dicts; all keys londec needs must be present at the root level. You tell londec which keys carry the meaningful values through a FieldMap:

from londec import FieldMap

FIELD_MAP = FieldMap(
    type_id="event_type",     # key that identifies the event type
    created_at="occurred_at", # key holding the event timestamp
    revoked_at="cancelled_at" # key holding the cancellation timestamp (None = active)
)

All other keys in the event dict — whether raw data, computed metrics, or anything else — are accessed directly by name in payload_match conditions. The caller is responsible for flattening nested structures and resolving any key collisions before passing events to londec.

No I/O, no ORM, no framework

londec operates on a list[dict]. It has no database queries, no ORM models, no HTTP calls. The caller is responsible for fetching and preparing the event list; londec is responsible for evaluating the condition tree.

This makes londec straightforward to test — no database setup, no mocks, no fixtures beyond a list of plain dicts:

def test_eligible_after_onboarding_delay():
    events = [
        {"event_type": "onboarding_complete", "occurred_at": datetime(2026, 3, 1, tzinfo=UTC), "cancelled_at": None},
    ]
    result = londec.decide(
        {"type": "delay", "activity_id": "onboarding_complete", "days": 7},
        events=events,
        field_map=FIELD_MAP,
        now=datetime(2026, 3, 8, tzinfo=UTC),
    )
    assert result == Decision(True, datetime(2026, 3, 8, tzinfo=UTC))

Core concepts

Events

A list[dict] sorted oldest-first. londec requires flat dicts — all keys it needs to read must be at the root level. The caller is responsible for flattening nested structures before passing events to londec.

events = [
    {
        "event_type": "account_created",
        "occurred_at": datetime(2026, 1, 1, tzinfo=UTC),
        "cancelled_at": None,
        "plan": "trial",
    },
    {
        "event_type": "order_placed",
        "occurred_at": datetime(2026, 1, 10, tzinfo=UTC),
        "cancelled_at": None,
        "amount": 49.00,
        "currency": "EUR",
        "lifetime_value": 49.00,
        "order_count": 1,
    },
    {
        "event_type": "order_placed",
        "occurred_at": datetime(2026, 2, 3, tzinfo=UTC),
        "cancelled_at": None,
        "amount": 79.00,
        "currency": "EUR",
        "lifetime_value": 128.00,
        "order_count": 2,
    },
]

A different system might use entirely different field names — londec works the same way, as long as the FieldMap describes the structure:

events = [
    {
        "kind": "build",
        "ts": datetime(2026, 3, 10, 9, 0, tzinfo=UTC),
        "reverted_at": None,
        "branch": "main",
        "triggered_by": "push",
        "coverage": 91,
        "lint_errors": 0,
    },
    {
        "kind": "deploy",
        "ts": datetime(2026, 3, 10, 9, 45, tzinfo=UTC),
        "reverted_at": None,
        "environment": "staging",
        "version": "2.1.0",
        "smoke_passed": True,
        "response_time_ms": 210,
    },
]

FIELD_MAP = FieldMap(
    type_id="kind",
    created_at="ts",
    revoked_at="reverted_at",
)

FieldMap

Maps three semantic roles to key names in your flat event dicts:

  • type_id — the key that identifies what kind of event this is
  • created_at — the key holding the event timestamp
  • revoked_at — the key holding the cancellation/revocation timestamp (None means the event is active)

All other event data — raw values, computed metrics, flags — is accessed directly by key name in payload_match conditions.

Condition

A plain dict with a "type" key. If "type" is absent, the condition is treated as unconditionally satisfied (True). Composite conditions use "list" to hold sub-conditions.

Result: Decision(satisfied, when)

class Decision(NamedTuple):
    satisfied: bool
    when: datetime.datetime | None = None

satisfied is always computed directly by whichever evaluator or combinator produced it — never inferred by comparing when to now after the fact. when, when present, means "since this date" if satisfied is True, or "predicted to become satisfied at this date" if satisfied is False. It's None whenever there's no meaningful date to report — a pure count-based check (e.g. event_not_happened), or a genuine dead end with no way to predict a future resolution.

  • Decision(False, None) — not satisfied, nothing to predict
  • Decision(False, datetime(...)) — not satisfied yet, but predicted to become satisfied at this date (e.g. a delay whose threshold hasn't been reached)
  • Decision(True, None) — satisfied, no timestamp available (e.g. event_happened_fewer_than)
  • Decision(True, datetime(...)) — satisfied since this date

Condition reference

Event occurrence

Type Satisfied when when
event_happened At least one non-revoked event of activity_id exists created_at of the most recent
event_not_happened No non-revoked events of activity_id exist None
event_happened_exactly Exactly x non-revoked events of activity_id created_at of the most recent
event_happened_fewer_than Fewer than x non-revoked events None
event_happened_at_least x or more non-revoked events None
event_revoked A revoked event of activity_id exists revoked_at of the most recent revoked event

Timing

Type Fields Satisfied when when
delay activity_id, days now >= created_at + days created_at + days — the threshold, whether or not it's been reached yet
is_taken_recently activity_id, duration_type ("days"/"hours"), duration Most recent event is within duration of now created_at of that event if satisfied, else None (a closing condition — no future re-opening to predict)
available_on_date_range start_date, end_date (ISO), timezone_offset (minutes) now is within the date range start_date (as datetime) before/during the range; None once the range has closed

Payload matching

payload_match checks the value at key in the matching event dict. activity_id is optional — when omitted, the condition checks the Nth most recent event across all event types.

seq_num (int or string, default 0) selects the event position: 0 is the most recent, 1 the second most recent, and so on. Revoked events are excluded before indexing.

Field Required Description
key Yes Key to look up in the event dict
answer Yes Expected value
activity_id No Filter to events of this type only
sub_type No Expression evaluator (see below); defaults to strict equality
seq_num No Position index (default 0 = most recent)

Sequence

Type Fields Satisfied when when
last_event_type_equals activity_id, seq_num The event at position seq_num (most-recent-first) has activity_id created_at of that event

Combinators

satisfied is always computed via genuine all()/any() over each child's own satisfied flag; when is aggregated separately, only among children that have one.

Type Alias satisfied when when (once satisfied)
AND MAX_AND Every child is satisfied latest among children's when
MIN_AND Every child is satisfied (same as AND) earliest among children's when
OR MIN_OR Any child is satisfied earliest among satisfied children's when
MAX_OR Any child is satisfied (same as OR) latest among satisfied children's when

sub_type expression evaluators

Used with payload_match to perform comparisons beyond strict equality.

sub_type Comparison
equals Numeric equality, falls back to string equality
ne Not equal (string comparison)
gt / gte Numeric greater-than / greater-than-or-equal
lt / lte Numeric less-than / less-than-or-equal
in Value is in a list
in_range / not_in_range Value within / outside [min, max]
contains_any_of Collection contains at least one of the given items
contains_none_of Collection contains none of the given items
contains_all_of Collection contains all of the given items
is_subset_of Value (as set) is a subset of the given set
is_not_subset_of Value (as set) is not a subset of the given set
true / false Value is truthy / falsy

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

londec-0.2.1.tar.gz (18.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

londec-0.2.1-py3-none-any.whl (12.5 kB view details)

Uploaded Python 3

File details

Details for the file londec-0.2.1.tar.gz.

File metadata

  • Download URL: londec-0.2.1.tar.gz
  • Upload date:
  • Size: 18.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.4

File hashes

Hashes for londec-0.2.1.tar.gz
Algorithm Hash digest
SHA256 1d5ffe8f2cb4de69019231648f8a72088f1b652ace765308bf4bceba16b10267
MD5 1de7266ccc5b26498b665e36d5765741
BLAKE2b-256 f58666b3f6afc11e653c54dc9df7093163caee6097bdbac9d033f7594b7e03b5

See more details on using hashes here.

File details

Details for the file londec-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: londec-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 12.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.4

File hashes

Hashes for londec-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 dc93de92c1bc97852f6165a6403db8eb303bdcd3a182c6d28a0a0032840fb470
MD5 64ab0a4b3ba3751d7dd81738fe23b265
BLAKE2b-256 e48a52f6fceadb6d9403568ba062e16aa9d06e8370bbb0d39f1a0df9ff543f10

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 files

0.2.0

2 files

0.1.0

2 files

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