Skip to main content

londec — Decision Maker

Evaluate tree-structured, JSON-serializable conditions against an ordered history of typed events. Each condition resolves to either False or the datetime it was first satisfied — 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/>→ Jan 18"]
    DL["delay · days: 14<br/>event_type: purchase<br/>→ Feb 1"]
    AND["AND<br/>max(Jan 18, Feb 1)"]
    R(["datetime(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 False when a condition is not satisfied, or the datetime it was first satisfied:

result = londec.decide(
    {"type": "delay", "activity_id": "trial_started", "days": 14},
    events=user_events,
    field_map=FIELD_MAP,
)
# False          → trial never started; nothing to schedule
# datetime(...)  → the moment the 14-day window opens; schedule the follow-up for then

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. When all branches resolve to datetimes, the combinator propagates them — rather than collapsing everything to a plain boolean — so the result remains useful for scheduling:

  • AND (MAX_AND) — satisfied when the last prerequisite is met; returns the latest datetime
  • MIN_AND — returns the earliest datetime (useful to know when the first prerequisite was met)
  • OR (MIN_OR) — satisfied as soon as any branch is; returns the earliest datetime
  • MAX_OR — returns the latest datetime 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,
    )
    assert result == 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: bool | datetime

  • False — condition not satisfied
  • True — condition satisfied, no timestamp available (e.g. event_not_happened)
  • datetime — the moment the condition became satisfied

Condition reference

Event occurrence

Type Satisfied when Returns
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 True
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 True
event_happened_at_least x or more non-revoked events True
event_revoked A revoked event of activity_id exists revoked_at of the most recent revoked event

Timing

Type Fields Satisfied when Returns
delay activity_id, days Always (if event exists) created_at + days — the datetime the delay window opens
is_taken_recently activity_id, duration_type ("days"/"hours"), duration Most recent event is within duration of now created_at of that event
available_on_date_range start_date, end_date (ISO), timezone_offset (minutes) now is within the date range start_date (as datetime)

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 Returns
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

Type Alias Behaviour
AND MAX_AND All sub-conditions must be satisfied; returns the latest datetime
MIN_AND All must be satisfied; returns the earliest datetime
OR MIN_OR Any sub-condition must be satisfied; returns the earliest datetime
MAX_OR Any must be satisfied; returns the latest datetime

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.1.0.tar.gz (14.4 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.1.0-py3-none-any.whl (11.1 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for londec-0.1.0.tar.gz
Algorithm Hash digest
SHA256 fdbbe08c7923feaf84bbd9b2bb4238b73df31785594b3df04329bbbfcba3ef69
MD5 745ee83891c954e1cc8a0f449d78c733
BLAKE2b-256 f238bc471939a1b0823ed9a53babe264cd31d863cbcadd8e2a8f700aef438dd5

See more details on using hashes here.

File details

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

File metadata

  • Download URL: londec-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 11.1 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9f325bebb7c99f83743e3c95a4abedb46c1c292b14b4b39939ba30108c2047d1
MD5 7896dc657ba8edd4f06b70c88af9ed26
BLAKE2b-256 924db756b99e637c63caf9aae34f6c063001180a6cf9d56bfb7f3f9edf5dd4ef

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.1

2 files

0.2.0

2 files

This release

0.1.0 This release

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