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.0.tar.gz (16.3 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.0-py3-none-any.whl (12.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: londec-0.2.0.tar.gz
  • Upload date:
  • Size: 16.3 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.0.tar.gz
Algorithm Hash digest
SHA256 b288c997e29cf05448bc469ecf56b28c94795fe27a2d35fd725c45a305bfc7bb
MD5 cce959bc65af48cc837c787786575975
BLAKE2b-256 6b26bfe5e23b25e79ccdb1b89d9a073e3460e0d57cec1daa684698816213e148

See more details on using hashes here.

File details

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

File metadata

  • Download URL: londec-0.2.0-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.0-py3-none-any.whl
Algorithm Hash digest
SHA256 21f1371835948001cbb37fd4544616c85b65c5421907b08dec6f3ce6a3299b67
MD5 116f9c9d5b643688f46ad5f8b661e616
BLAKE2b-256 f490ce593c20b610d3c86f6a198cc3dc8439cf2af496d42033a11cce7b4bc923

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.1

2 files

This release

0.2.0 This release

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